diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f0f2d6d4..8cecaf201 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_dispatch: push: branches: - main @@ -130,14 +131,13 @@ jobs: # against dependencies nobody committed. The drift it papered over was real (five packages committed to # shared without regenerating its lock) and is fixed at the source; if it recurs, this should stop. npm --prefix ../shared ci - # Both packages this app consumes, because npm resolves each file: dep to its dist/ (jest - # reads src/, but tsc and metro read dist/). rag was never built at all before. + # Build every shared package this app consumes, because npm resolves each file: dependency + # to its dist/ (Jest reads src/, but TypeScript and Metro read dist/). npm --prefix ../shared/packages/sync run build npm --prefix ../shared/packages/rag run build - # speech too. It is a file: dep like the other two and six src files import it, but it was - # never built here - so every gate failed on "Cannot find module '@offgrid/speech'" (1966 - # errors in one run) long before it reached anything real. + npm --prefix ../shared/packages/models run build npm --prefix ../shared/packages/speech run build + npm --prefix ../shared/packages/ui run build - name: Install dependencies id: install @@ -183,6 +183,11 @@ jobs: - name: Run Jest tests if: ${{ !cancelled() && steps.install.outcome == 'success' }} + env: + # The serial RN + jsdom suite retains more than Node's default 2 GB heap before + # coverage is written. The macOS runner has 14 GB; keep enough headroom for native + # tooling while allowing the complete suite to finish without weakening the gate. + NODE_OPTIONS: --max-old-space-size=8192 run: npx jest --coverage --forceExit --runInBand - name: Install Android NDK diff --git a/.husky/pre-push b/.husky/pre-push index 3b091cc74..dcb6dec08 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -54,7 +54,13 @@ if [ -n "$PUSHED_JS" ]; then npx tsc --noEmit echo "▶ JS/TS tests (related to changed files)..." - echo "$PUSHED_JS" | tr '\n' '\0' | xargs -0 npx jest --findRelatedTests --passWithNoTests + # Rendered RN journeys share native-boundary state and are memory-heavy. Parallel workers + # starve one another and turn normal 10-second journeys into false multi-minute timeouts. + # CI runs this same graph serially; keep the local merge gate deterministic too. + # Some React Native boundary shims keep native-style handles alive after Jest has completed every + # assertion. The repository's full test command already force-closes those handles. Apply the same + # completion policy here so a fully passing related suite does not return a false non-zero status. + echo "$PUSHED_JS" | tr '\n' '\0' | xargs -0 npx jest --findRelatedTests --passWithNoTests --runInBand --forceExit echo "▶ Architecture gate (dependency-cruiser)..." npm run depcruise diff --git a/App.tsx b/App.tsx index 844089f45..c3ac6ce26 100644 --- a/App.tsx +++ b/App.tsx @@ -4,25 +4,30 @@ */ import 'react-native-gesture-handler'; -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import { ActivityIndicator, View, StyleSheet, LogBox } from 'react-native'; import { SystemBars } from 'react-native-edge-to-edge'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { NavigationContainer } from '@react-navigation/native'; import { AppNavigator } from './src/navigation'; +import { + appNavigationRef, + useProExpiryRedirect, +} from './src/navigation/useProExpiryRedirect'; import { useTheme } from './src/theme'; import { hardwareService, modelManager, authService, ragService, remoteServerManager } from './src/services'; import logger from './src/utils/logger'; import { useAppStore, useAuthStore, useRemoteServerStore, useWhisperStore } from './src/stores'; import { useDebugLogsStore } from './src/stores/debugLogsStore'; -import { initDebugLogFile, appendDebugLine } from './src/utils/debugLogFile'; +import { initDebugLogFile, appendDebugLine, stopDebugLogFile } from './src/utils/debugLogFile'; import { startStartupMemoryProbe } from './src/services/startupMemoryProbe'; import { loadProFeatures } from './src/bootstrap/loadProFeatures'; import { hydrateDownloadStore } from './src/services/downloadHydration'; import { initActiveDownloadPersistence } from './src/services/activeDownloadPersistence'; import { restoreQueuedDownloads } from './src/services/restoreQueuedDownloads'; import { startLoadPolicySync } from './src/services/loadPolicySync'; +import { startNetworkReconnectWatcher, stopNetworkReconnectWatcher } from './src/services/networkReconnect'; import { registerCoreDownloadProviders } from './src/services/modelDownloadService/registerProviders'; import { useDownloadListeners } from './src/hooks/useDownloads'; import { KeyboardProvider } from 'react-native-keyboard-controller'; @@ -34,6 +39,7 @@ import { ErrorBoundary } from './src/components/ErrorBoundary'; LogBox.ignoreAllLogs(); // Suppress all logs +let stopStartupProbe: (() => void) | null = null; // Dev-only: mirror logger output into the in-app Debug Logs viewer. The whole block // is behind __DEV__, so release builds keep main's no-op logger (zero logging cost). if (__DEV__) { @@ -60,7 +66,7 @@ if (__DEV__) { // Immediately after the sink exists, so the first sample lands before anything heavy runs. The app // was being killed by iOS at launch with the log going silent half a second in; this says where it // stops and what memory was doing when it did. - startStartupMemoryProbe(); + stopStartupProbe = startStartupMemoryProbe(); } const ensureRemoteServerStoreHydrated = async () => { @@ -72,12 +78,20 @@ const ensureRemoteServerStoreHydrated = async () => { }; function App() { + useEffect(() => () => { + stopStartupProbe?.(); + stopStartupProbe = null; + stopDebugLogFile(); + }, []); + useDownloadListeners(); // Reactive: when Pro is activated at runtime (license key → loadProFeatures), // the appRoot slot (TTS engine bridge) registers and this re-renders to mount // it live — no restart needed. const AppRoot = useSlot(SLOTS.appRoot); + const applyPendingProRedirect = useProExpiryRedirect(); const [isInitializing, setIsInitializing] = useState(true); + const startupGeneration = useRef(0); const setDeviceInfo = useAppStore((s) => s.setDeviceInfo); const setModelRecommendation = useAppStore((s) => s.setModelRecommendation); const setDownloadedModels = useAppStore((s) => s.setDownloadedModels); @@ -205,9 +219,13 @@ function App() { })().catch((error) => { logger.error('[App] Download-state recovery failed:', error); }); - }, [setDownloadedModels, setDownloadedImageModels]); + }, [ + reattachTextDownloadRecovery, + setDownloadedModels, + setDownloadedImageModels, + ]); - const initializeApp = useCallback(async () => { + const initializeApp = useCallback(async (generation: number) => { try { // Ensure persisted download metadata is loaded before restore logic reads it. logger.log('[BOOT] app store hydrate'); @@ -258,9 +276,18 @@ function App() { // Initialize remote server providers in the background — don't block // the home screen while fetching models from potentially unreachable servers. - remoteServerManager.initializeProviders().catch((err) => { - logger.error('[App] Failed to initialize remote server providers:', err); - }); + remoteServerManager + .initializeProviders() + .catch((err) => { + logger.error('[App] Failed to initialize remote server providers:', err); + }) + .finally(() => { + if (generation !== startupGeneration.current) return; + // Recovery and provider initialization both update the registry and remote-server store. + // Start recovery only after initialization releases those owners. A failed initialization + // must still start the watcher so a later network recovery can repair the connection. + startNetworkReconnectWatcher(); + }); // Check if passphrase is set and lock app if needed logger.log('[BOOT] auth passphrase check'); @@ -315,7 +342,12 @@ function App() { ]); useEffect(() => { - initializeApp(); + const generation = ++startupGeneration.current; + initializeApp(generation); + return () => { + startupGeneration.current += 1; + stopNetworkReconnectWatcher(); + }; }, [initializeApp]); const handleUnlock = useCallback(() => { @@ -353,6 +385,8 @@ function App() { {AppRoot ? : null} Promise.resolve({ textModels: [], imageModels: [] })), watchDownload: jest.fn(), }; +const mockInitializeProviders = jest.fn(() => Promise.resolve()); +const mockStartNetworkReconnectWatcher = jest.fn(); jest.mock('../src/navigation', () => ({ AppNavigator: () => null, @@ -135,9 +137,14 @@ jest.mock('../src/hooks/useDownloads', () => ({ jest.mock('../src/services/loadPolicySync', () => ({ startLoadPolicySync: jest.fn(() => jest.fn()), })); +jest.mock('../src/services/networkReconnect', () => ({ + startNetworkReconnectWatcher: mockStartNetworkReconnectWatcher, + stopNetworkReconnectWatcher: jest.fn(), +})); jest.mock('../src/utils/debugLogFile', () => ({ initDebugLogFile: jest.fn(), appendDebugLine: jest.fn(), + stopDebugLogFile: jest.fn(), })); jest.mock('../src/services', () => ({ @@ -153,7 +160,7 @@ jest.mock('../src/services', () => ({ ensureReady: jest.fn(() => Promise.resolve()), }, remoteServerManager: { - initializeProviders: jest.fn(() => Promise.resolve()), + initializeProviders: mockInitializeProviders, }, })); @@ -167,12 +174,14 @@ jest.mock('../src/services', () => ({ describe('App', () => { beforeEach(() => { jest.clearAllMocks(); + mockInitializeProviders.mockResolvedValue(undefined); }); it('restores in-flight downloads at startup and watches each to completion', async () => { const App = require('../App').default; + let renderer: ReactTestRenderer.ReactTestRenderer; await ReactTestRenderer.act(async () => { - ReactTestRenderer.create(); + renderer = ReactTestRenderer.create(); // Flush the async startup chain (hydrate → reattach → restore → watch). for (let i = 0; i < 20; i++) await Promise.resolve(); }); @@ -188,5 +197,59 @@ describe('App', () => { expect.any(Function), expect.any(Function), ); + await ReactTestRenderer.act(async () => { + renderer!.unmount(); + }); + }); + + it('starts reconnect recovery only after remote provider initialization settles', async () => { + let finishInitialization: (() => void) | undefined; + mockInitializeProviders.mockImplementationOnce(() => new Promise((resolve) => { + finishInitialization = resolve; + })); + + const App = require('../App').default; + let renderer: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create(); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + + expect(mockInitializeProviders).toHaveBeenCalledTimes(1); + expect(mockStartNetworkReconnectWatcher).not.toHaveBeenCalled(); + + await ReactTestRenderer.act(async () => { + finishInitialization?.(); + await Promise.resolve(); + }); + + expect(mockStartNetworkReconnectWatcher).toHaveBeenCalledTimes(1); + await ReactTestRenderer.act(async () => { + renderer!.unmount(); + }); + }); + + it('does not start reconnect recovery when provider initialization settles after unmount', async () => { + let finishInitialization: (() => void) | undefined; + mockInitializeProviders.mockImplementationOnce(() => new Promise((resolve) => { + finishInitialization = resolve; + })); + + const App = require('../App').default; + let renderer: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create(); + for (let i = 0; i < 20; i++) await Promise.resolve(); + }); + + await ReactTestRenderer.act(async () => { + renderer!.unmount(); + }); + await ReactTestRenderer.act(async () => { + finishInitialization?.(); + await Promise.resolve(); + }); + + expect(mockStartNetworkReconnectWatcher).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/fixtures/hf/vision-repos.json b/__tests__/fixtures/hf/vision-repos.json index a4b1c245f..63d48974a 100644 --- a/__tests__/fixtures/hf/vision-repos.json +++ b/__tests__/fixtures/hf/vision-repos.json @@ -131,6 +131,90 @@ } } }, + "unsloth/Qwen3.5-2B-GGUF": { + "exists": true, + "ggufFiles": [ + "Qwen3.5-2B-BF16.gguf", + "Qwen3.5-2B-IQ4_NL.gguf", + "Qwen3.5-2B-IQ4_XS.gguf", + "Qwen3.5-2B-Q3_K_M.gguf", + "Qwen3.5-2B-Q3_K_S.gguf", + "Qwen3.5-2B-Q4_0.gguf", + "Qwen3.5-2B-Q4_1.gguf", + "Qwen3.5-2B-Q4_K_M.gguf", + "Qwen3.5-2B-Q4_K_S.gguf", + "Qwen3.5-2B-Q5_K_M.gguf", + "Qwen3.5-2B-Q5_K_S.gguf", + "Qwen3.5-2B-Q6_K.gguf", + "Qwen3.5-2B-Q8_0.gguf", + "Qwen3.5-2B-UD-IQ2_M.gguf", + "Qwen3.5-2B-UD-IQ2_XXS.gguf", + "Qwen3.5-2B-UD-IQ3_XXS.gguf", + "Qwen3.5-2B-UD-Q2_K_XL.gguf", + "Qwen3.5-2B-UD-Q3_K_XL.gguf", + "Qwen3.5-2B-UD-Q4_K_XL.gguf", + "Qwen3.5-2B-UD-Q5_K_XL.gguf", + "Qwen3.5-2B-UD-Q6_K_XL.gguf", + "Qwen3.5-2B-UD-Q8_K_XL.gguf", + "mmproj-BF16.gguf", + "mmproj-F16.gguf", + "mmproj-F32.gguf" + ], + "probe": { + "model": { + "name": "Qwen3.5-2B-BF16.gguf", + "status": 206, + "magic": "GGUF" + }, + "mmproj": { + "name": "mmproj-F16.gguf", + "status": 206, + "magic": "GGUF" + } + } + }, + "unsloth/Qwen3.5-9B-GGUF": { + "exists": true, + "ggufFiles": [ + "Qwen3.5-9B-BF16.gguf", + "Qwen3.5-9B-IQ4_NL.gguf", + "Qwen3.5-9B-IQ4_XS.gguf", + "Qwen3.5-9B-Q3_K_M.gguf", + "Qwen3.5-9B-Q3_K_S.gguf", + "Qwen3.5-9B-Q4_0.gguf", + "Qwen3.5-9B-Q4_1.gguf", + "Qwen3.5-9B-Q4_K_M.gguf", + "Qwen3.5-9B-Q4_K_S.gguf", + "Qwen3.5-9B-Q5_K_M.gguf", + "Qwen3.5-9B-Q5_K_S.gguf", + "Qwen3.5-9B-Q6_K.gguf", + "Qwen3.5-9B-Q8_0.gguf", + "Qwen3.5-9B-UD-IQ2_M.gguf", + "Qwen3.5-9B-UD-IQ2_XXS.gguf", + "Qwen3.5-9B-UD-IQ3_XXS.gguf", + "Qwen3.5-9B-UD-Q2_K_XL.gguf", + "Qwen3.5-9B-UD-Q3_K_XL.gguf", + "Qwen3.5-9B-UD-Q4_K_XL.gguf", + "Qwen3.5-9B-UD-Q5_K_XL.gguf", + "Qwen3.5-9B-UD-Q6_K_XL.gguf", + "Qwen3.5-9B-UD-Q8_K_XL.gguf", + "mmproj-BF16.gguf", + "mmproj-F16.gguf", + "mmproj-F32.gguf" + ], + "probe": { + "model": { + "name": "Qwen3.5-9B-BF16.gguf", + "status": 206, + "magic": "GGUF" + }, + "mmproj": { + "name": "mmproj-F16.gguf", + "status": 206, + "magic": "GGUF" + } + } + }, "ggml-org/SmolVLM2-500M-Video-Instruct-GGUF": { "exists": true, "ggufFiles": [ @@ -386,6 +470,11 @@ "name": "gemma-3-4b-it-f16.gguf", "status": 206, "magic": "GGUF" + }, + "mmproj": { + "name": "mmproj-model-f16.gguf", + "status": 206, + "magic": "GGUF" } } }, @@ -447,6 +536,11 @@ "name": "Mistral-Small-3.1-24B-Instruct-2503-UD-IQ2_M.gguf", "status": 206, "magic": "GGUF" + }, + "mmproj": { + "name": "mmproj-Mistral-Small-3.1-24B-Instruct-2503-Q8_0.gguf", + "status": 206, + "magic": "GGUF" } } }, @@ -483,6 +577,11 @@ "name": "ggml-model-f16.gguf", "status": 206, "magic": "GGUF" + }, + "mmproj": { + "name": "mmproj-model-f16.gguf", + "status": 206, + "magic": "GGUF" } } }, @@ -497,6 +596,11 @@ "name": "moondream2-text-model-f16.gguf", "status": 206, "magic": "GGUF" + }, + "mmproj": { + "name": "moondream2-mmproj-f16.gguf", + "status": 206, + "magic": "GGUF" } } }, diff --git a/__tests__/hardening/batch5-kokoroDownloadError.test.ts b/__tests__/hardening/batch5-kokoroDownloadError.test.ts index 2bf55b1f6..ac9397398 100644 --- a/__tests__/hardening/batch5-kokoroDownloadError.test.ts +++ b/__tests__/hardening/batch5-kokoroDownloadError.test.ts @@ -16,16 +16,35 @@ * is exercised nowhere — the ttsStore setVoice tests mock engine.setVoice entirely. */ import { BareResourceFetcher } from 'react-native-executorch-bare-resource-fetcher'; -import { KokoroEngine } from '../../pro/audio/engine/tts/engines/kokoro/KokoroEngine'; +import { + KokoroEngine, + type KokoroBridgeHandle, +} from '../../pro/audio/engine/tts/engines/kokoro/KokoroEngine'; +import type { KokoroVoiceId } from '../../pro/audio/engine/tts/engines/kokoro/voices'; const fetchResources = (BareResourceFetcher as any).fetch as jest.Mock; -const listDownloadedFiles = BareResourceFetcher.listDownloadedFiles as jest.Mock; +const listDownloadedFiles = + BareResourceFetcher.listDownloadedFiles as jest.Mock; beforeEach(() => { fetchResources?.mockReset().mockResolvedValue(undefined); listDownloadedFiles?.mockReset().mockResolvedValue([]); }); +function attachSelectedVoiceBridge(engine: KokoroEngine): void { + const bridge: KokoroBridgeHandle = { + speak: async () => undefined, + stop: () => undefined, + pause: () => undefined, + resume: () => undefined, + setSpeed: () => undefined, + setKeepAlive: () => undefined, + }; + engine._setMountRequester(() => { + engine._setBridge(bridge, engine.getActiveVoice()!.id as KokoroVoiceId); + }); +} + describe('KokoroEngine — download failure (offline / interrupted fetch)', () => { it('a REAL fetch rejection lands the engine in the error phase, records the message, and rethrows', async () => { // The offline case: BareResourceFetcher.fetch rejects with a genuine error (NOT the @@ -35,7 +54,9 @@ describe('KokoroEngine — download failure (offline / interrupted fetch)', () = const engine = new KokoroEngine(); fetchResources.mockRejectedValueOnce(new Error('Network is unreachable')); - await expect(engine.downloadAssets()).rejects.toThrow(/network is unreachable/i); + await expect(engine.downloadAssets()).rejects.toThrow( + /network is unreachable/i, + ); expect(engine.getPhase()).toBe('error'); expect(engine.getLastDownloadError()).toMatch(/network is unreachable/i); @@ -54,7 +75,11 @@ describe('KokoroEngine — download failure (offline / interrupted fetch)', () = expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ code: 'KOKORO_DOWNLOAD', recoverable: true, message: expect.stringMatching(/interrupted/i) }), + expect.objectContaining({ + code: 'KOKORO_DOWNLOAD', + recoverable: true, + message: expect.stringMatching(/interrupted/i), + }), ); }); @@ -88,8 +113,38 @@ describe('KokoroEngine — download failure (offline / interrupted fetch)', () = }); describe('KokoroEngine.setVoice — active voice + completeness + events', () => { + it('serializes a voice-pack fetch behind an active base-model download', async () => { + const engine = new KokoroEngine(); + attachSelectedVoiceBridge(engine); + let finishBase!: () => void; + fetchResources + .mockImplementationOnce( + () => + new Promise(resolve => { + finishBase = resolve; + }), + ) + .mockResolvedValueOnce(undefined); + + const baseDownload = engine.downloadAssets(); + const voiceSwitch = engine.setVoice('hf_alpha'); + + await Promise.resolve(); + await Promise.resolve(); + expect(fetchResources).toHaveBeenCalledTimes(1); + + finishBase(); + await baseDownload; + await voiceSwitch; + + expect(fetchResources).toHaveBeenCalledTimes(2); + expect(engine.getActiveVoice()?.id).toBe('hf_alpha'); + expect(engine.isFullyDownloaded()).toBe(true); + }); + it('updates the active voice and reflects it via getActiveVoice()', async () => { const engine = new KokoroEngine(); + attachSelectedVoiceBridge(engine); // Default active voice is af_heart. expect(engine.getActiveVoice()?.id).toBe('af_heart'); @@ -101,6 +156,7 @@ describe('KokoroEngine.setVoice — active voice + completeness + events', () => it('emits voiceChanged with the new voice id', async () => { const engine = new KokoroEngine(); + attachSelectedVoiceBridge(engine); const onVoiceChanged = jest.fn(); engine.on('voiceChanged', onVoiceChanged); fetchResources.mockResolvedValueOnce(undefined); @@ -112,6 +168,7 @@ describe('KokoroEngine.setVoice — active voice + completeness + events', () => it('records genuine completion once the new voice fetch resolves (reads downloaded)', async () => { const engine = new KokoroEngine(); + attachSelectedVoiceBridge(engine); fetchResources.mockResolvedValueOnce(undefined); await engine.setVoice('am_michael'); @@ -122,23 +179,24 @@ describe('KokoroEngine.setVoice — active voice + completeness + events', () => it('rejects an unknown voice id without touching the active voice', async () => { const engine = new KokoroEngine(); - await expect(engine.setVoice('not_a_real_voice')).rejects.toThrow(/unknown kokoro voice/i); + await expect(engine.setVoice('not_a_real_voice')).rejects.toThrow( + /unknown kokoro voice/i, + ); expect(engine.getActiveVoice()?.id).toBe('af_heart'); // unchanged expect(fetchResources).not.toHaveBeenCalled(); }); - it('a failed voice-asset fetch is tolerated: active voice still switches, voiceChanged still emits', async () => { - // setVoice reflects the new voice immediately (the picker reads active voice) and the - // asset prefetch is best-effort — a fetch failure is logged, not thrown, so the picker - // never wedges. The store layer owns the switching-flag/spinner lifecycle. + it('a failed voice-asset fetch rejects and keeps the last usable voice active', async () => { const engine = new KokoroEngine(); const onVoiceChanged = jest.fn(); engine.on('voiceChanged', onVoiceChanged); fetchResources.mockRejectedValueOnce(new Error('voice fetch offline')); - await expect(engine.setVoice('am_santa')).resolves.toBeUndefined(); + await expect(engine.setVoice('am_santa')).rejects.toThrow( + 'voice fetch offline', + ); - expect(engine.getActiveVoice()?.id).toBe('am_santa'); - expect(onVoiceChanged).toHaveBeenCalledWith('am_santa'); + expect(engine.getActiveVoice()?.id).toBe('af_heart'); + expect(onVoiceChanged).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/hardening/batch8-remote-tool-gate.test.ts b/__tests__/hardening/batch8-remote-tool-gate.test.ts index 846ebda65..6c6644c48 100644 --- a/__tests__/hardening/batch8-remote-tool-gate.test.ts +++ b/__tests__/hardening/batch8-remote-tool-gate.test.ts @@ -97,20 +97,7 @@ describe('Batch 8 — remote server tool-calling capability gate (request builde expect(body.tool_choice).toBeUndefined(); }); - /** - * BUG-FOUND — the request builder does not consult the discovered - * `supportsToolCalling` capability. src/services/providers/openAICompatibleProvider.ts - * line ~102 gates only on `options.tools.length > 0`: - * - * ...(options.tools && options.tools.length > 0 && { tools, tool_choice: 'auto' }) - * - * So a server that advertised supportsToolCalling === false at discovery STILL - * receives the tools array. The fix is to also require - * `this.modelCapabilities.supportsToolCalling` before adding tools. This test is - * the exact fails-before / passes-after case for that fix. Skipped until src is - * fixed (per assignment: real src bug → do not edit src, mark BUG-FOUND + .skip). - */ - it.skip('BUG-FOUND: omits tools + tool_choice when the server advertised supportsToolCalling=false', async () => { + it('omits tools + tool_choice when the server advertised supportsToolCalling=false', async () => { const body = await captureRequestBody({ supportsToolCalling: false, tools: TOOLS }); expect(body.tools).toBeUndefined(); expect(body.tool_choice).toBeUndefined(); diff --git a/__tests__/harness/chatHarness.ts b/__tests__/harness/chatHarness.ts index 4be2bc5aa..54a2ab862 100644 --- a/__tests__/harness/chatHarness.ts +++ b/__tests__/harness/chatHarness.ts @@ -20,7 +20,13 @@ * await h.send('what is the capital of France', { text: 'Paris.' }); // types, presses send, awaits reply * expect(h.view.queryByText(/Paris\./)).not.toBeNull(); */ -import { installNativeBoundary, requireRTL, GB, type RamProfile, type CompletionMeta } from './nativeBoundary'; +import { + installNativeBoundary, + requireRTL, + GB, + type RamProfile, + type CompletionMeta, +} from './nativeBoundary'; import { createDownloadedModel } from '../utils/factories'; /** Shared route params the test's navigation mock reads (set by setupChatScreen). */ @@ -64,31 +70,47 @@ export interface ChatHarnessOptions { export async function setupChatScreen(opts: ChatHarnessOptions) { const platform = opts.platform ?? 'android'; const ram = opts.ram ?? { platform, totalBytes: 12 * GB, availBytes: 8 * GB }; - const boundary = installNativeBoundary({ llama: opts.engine === 'llama', llamaChatTemplate: opts.chatTemplate, fs: true, ram, whisper: opts.whisper, download: opts.download }); + const boundary = installNativeBoundary({ + llama: opts.engine === 'llama', + llamaChatTemplate: opts.chatTemplate, + fs: true, + ram, + whisper: opts.whisper, + download: opts.download, + }); // Global boundary polyfill: React 19's error reporter calls window.dispatchEvent; in the node test // env there is no window, so an unrelated crash would mask real errors. This is a jsdom/global shim, // NOT app logic. const g = globalThis as unknown as { window?: Record }; - if (!g.window) g.window = { dispatchEvent: () => true, addEventListener: () => {}, removeEventListener: () => {} }; + if (!g.window) + g.window = { + dispatchEvent: () => true, + addEventListener: () => {}, + removeEventListener: () => {}, + }; - const React = require('react'); const rtl = requireRTL(); const { hardwareService } = require('../../src/services/hardware'); const { useAppStore, useChatStore } = require('../../src/stores'); - // BOUNDARY (not a gesture): a downloaded model = a persisted record (@local_llm/downloaded_models) + the // file on disk — exactly what a real download leaves. Downloading is native and can't be gestured in jest, // so we pre-place ONLY this. Everything above it (hydration, the picker, selection, load) runs for real. - - const AsyncStorage = require('@react-native-async-storage/async-storage').default ?? require('@react-native-async-storage/async-storage'); - const { activeModelService } = require('../../src/services/activeModelService'); + + const AsyncStorage = + require('@react-native-async-storage/async-storage').default ?? + require('@react-native-async-storage/async-storage'); + const { + activeModelService, + } = require('../../src/services/activeModelService'); const { HomeScreen } = require('../../src/screens/HomeScreen'); - + const docs = boundary.fs!.DocumentDirectoryPath; - const fileName = opts.modelFileName ?? (opts.engine === 'llama' ? 'ggml-small.gguf' : 'gemma.litertlm'); + const fileName = + opts.modelFileName ?? + (opts.engine === 'llama' ? 'ggml-small.gguf' : 'gemma.litertlm'); const modelPath = `${docs}/models/${fileName}`; boundary.fs!.seedFile(modelPath, 500 * 1024 * 1024); // fileSize drives the residency budget. The factory default is 4GB, which under the GPU-aware text @@ -97,32 +119,77 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { // A realistic small model (2GB) is device-faithful and loads under the budget; memory/OOM tests set // their own explicit sizes + RAM profiles and are unaffected. const fileSize = opts.modelFileSizeBytes ?? 2 * 1024 * 1024 * 1024; - const model = createDownloadedModel({ id: 'm', name: opts.modelName ?? 'Test Model', engine: opts.engine, filePath: modelPath, fileName, fileSize, liteRTVision: opts.vision, liteRTAudio: opts.audio }); - await AsyncStorage.setItem('@local_llm/downloaded_models', JSON.stringify([model])); + const model = createDownloadedModel({ + id: 'm', + name: opts.modelName ?? 'Test Model', + engine: opts.engine, + filePath: modelPath, + fileName, + fileSize, + liteRTVision: opts.vision, + liteRTAudio: opts.audio, + }); + await AsyncStorage.setItem( + '@local_llm/downloaded_models', + JSON.stringify([model]), + ); await hardwareService.refreshMemoryInfo(); // Boundary: dismiss the onboarding spotlight tour. When a whisper model is present the voice-hint // spotlight (step 12) fires and wraps the send button in an AttachStep, which intercepts the composer // gesture in tests. The tour is unrelated to any behavior under test, so mark it done up front. - + useAppStore.setState({ checklistDismissed: true }); // Activate PRO (audio/voice mode header toggle, audio layout, TTS, MCP) via the real bootstrap BEFORE any // screen mounts, so pro slots render in Home + ChatScreen. Reusable seam (proHarness.installPro). - if (opts.pro) { const { installPro } = require('./proHarness'); await installPro(); } + if (opts.pro) { + const { installPro } = require('./proHarness'); + await installPro(); + } // GESTURE: mount the real Home screen — its REAL hydration loads the record — then open the picker and TAP // the model row. The real handleSelectTextModel sets it active (no setState activeModelId shortcut). - const home = rtl.render(React.createElement(HomeScreen, { navigation: { navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} } })); - await rtl.waitFor(() => { expect(useAppStore.getState().downloadedModels.length).toBeGreaterThan(0); }, { timeout: 4000 }); - rtl.fireEvent.press(await rtl.waitFor(() => home.getByTestId('browse-models-button'))); - const rows = await rtl.waitFor(() => { const r = home.queryAllByTestId('model-item'); expect(r.length).toBeGreaterThan(0); return r; }, { timeout: 4000 }); + const home = rtl.render( + React.createElement(HomeScreen, { + navigation: { + navigate: () => {}, + goBack: () => {}, + setOptions: () => {}, + addListener: () => () => {}, + }, + }), + ); + await rtl.waitFor( + () => { + expect(useAppStore.getState().downloadedModels.length).toBeGreaterThan(0); + }, + { timeout: 4000 }, + ); + rtl.fireEvent.press( + await rtl.waitFor(() => home.getByTestId('browse-models-button')), + ); + const rows = await rtl.waitFor( + () => { + const r = home.queryAllByTestId('model-item'); + expect(r.length).toBeGreaterThan(0); + return r; + }, + { timeout: 4000 }, + ); rtl.fireEvent.press(rows[0]); - await rtl.waitFor(() => { expect(useAppStore.getState().activeModelId).toBe('m'); }, { timeout: 4000 }); + await rtl.waitFor( + () => { + expect(useAppStore.getState().activeModelId).toBe('m'); + }, + { timeout: 4000 }, + ); // GESTURE: with the model now selected, tap "New Chat" on Home — the real way a user starts a chat. A new // chat has NO conversation yet; it is created on the first message (real app behavior). No createConversation. - rtl.fireEvent.press(await rtl.waitFor(() => home.getByTestId('new-chat-button'))); + rtl.fireEvent.press( + await rtl.waitFor(() => home.getByTestId('new-chat-button')), + ); home.unmount(); // Load via the REAL load path (the app loads lazily on the first send; we trigger the same path so the @@ -138,19 +205,24 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { // token-flush timer that fires inside the NEXT suite and fails it, which is why exactly one rendered // suite failed per run with a different name every time. { - const { generationService } = require('../../src/services'); - (globalThis as unknown as { __GEN_CLEANUP__?: () => void }).__GEN_CLEANUP__ = () => { - generationService.stopGeneration().catch(() => { }); - }; + ( + globalThis as unknown as { __GEN_CLEANUP__?: () => Promise } + ).__GEN_CLEANUP__ = () => generationService.stopGeneration(); } routeHolder.params = {}; // new chat — the first send() creates the conversation return { - boundary, React, rtl, useAppStore, useChatStore, + boundary, + React, + rtl, + useAppStore, + useChatStore, /** The active conversation id — a NEW chat has none until the first send() creates it. */ - get conversationId(): string | null { return useChatStore.getState().activeConversationId; }, + get conversationId(): string | null { + return useChatStore.getState().activeConversationId; + }, view: null as ReturnType | null, /** @@ -159,14 +231,17 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * live when we return to chat. NOT settings.updateSettings seeding. */ enableToolViaUI(toolId: string, value: boolean = true) { - const { ToolsScreen } = require('../../src/screens/ToolsScreen'); const { Switch } = require('react-native'); const tools = rtl.render(React.createElement(ToolsScreen, {})); const row = tools.getByTestId(`tool-picker-row-${toolId}`); // The RN Switch toggles via onValueChange (not press) — locate it in the row and flip it. - rtl.fireEvent(rtl.within(row).UNSAFE_getByType(Switch), 'valueChange', value); + rtl.fireEvent( + rtl.within(row).UNSAFE_getByType(Switch), + 'valueChange', + value, + ); tools.unmount(); }, @@ -175,8 +250,9 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * value into the real numeric input on the real TextGenerationSection — NOT updateSettings seeding. */ setTextSettingViaUI(key: string, value: number) { - - const { TextGenerationSection } = require('../../src/components/GenerationSettingsModal/TextGenerationSection'); + const { + TextGenerationSection, + } = require('../../src/components/GenerationSettingsModal/TextGenerationSection'); const s = rtl.render(React.createElement(TextGenerationSection, {})); rtl.fireEvent.press(s.getByTestId(`setting-${key}-value-button`)); const input = s.getByTestId(`setting-${key}-input`); @@ -187,7 +263,7 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { /** Let async work (tool loop → tool-result bubble render) settle before asserting. */ async settle(ms = 300) { - await new Promise((r) => setTimeout(r, ms)); + await new Promise(r => setTimeout(r, ms)); }, /** @@ -197,8 +273,12 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { */ async cycleImageMode() { const view = this.view!; - rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('quick-settings-button'))); - rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('quick-image-mode'))); + rtl.fireEvent.press( + await rtl.waitFor(() => view.getByTestId('quick-settings-button')), + ); + rtl.fireEvent.press( + await rtl.waitFor(() => view.getByTestId('quick-image-mode')), + ); }, /** @@ -206,18 +286,45 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * is NOT activated here: activation is a real gesture (cycleImageMode's toggle sets activeImageModelId * when an image model is downloaded). Settles first so the mount's hydration has cleared the empty disk. */ - async placeImageModel(imgOpts: { id?: string; modelPath?: string; backend?: 'mnn' | 'qnn' | 'coreml'; size?: number } = {}) { - const { id = 'sd', modelPath: imgModelPath = '/models/sd', backend = 'coreml', size } = imgOpts; - + async placeImageModel( + imgOpts: { + id?: string; + modelPath?: string; + backend?: 'mnn' | 'qnn' | 'coreml'; + size?: number; + } = {}, + ) { + const { + id = 'sd', + modelPath: imgModelPath = '/models/sd', + backend = 'coreml', + size, + } = imgOpts; + const { createONNXImageModel } = require('../utils/factories'); - const imgModel = createONNXImageModel({ id, name: 'SD', modelPath: imgModelPath, backend, ...(size != null ? { size } : {}) }); + const imgModel = createONNXImageModel({ + id, + name: 'SD', + modelPath: imgModelPath, + backend, + ...(size != null ? { size } : {}), + }); // A downloaded+extracted image model IS its file set on disk (the boundary) — seed the exact files the // real integrity gate + native load require, so the REAL load path runs (mnn/qnn validate the dir; // coreml doesn't). No pre-marking-loaded shortcut. - const seedFile = (name: string) => boundary.fs!.seedFile(`${imgModelPath}/${name}`, 8 * 1024 * 1024); + const seedFile = (name: string) => + boundary.fs!.seedFile(`${imgModelPath}/${name}`, 8 * 1024 * 1024); if (backend === 'mnn' || backend === 'qnn') { ['pos_emb.bin', 'token_emb.bin', 'tokenizer.json'].forEach(seedFile); - if (backend === 'mnn') ['unet.mnn', 'unet.mnn.weight', 'vae_decoder.mnn', 'vae_decoder.mnn.weight', 'clip_v2.mnn', 'clip_v2.mnn.weight'].forEach(seedFile); + if (backend === 'mnn') + [ + 'unet.mnn', + 'unet.mnn.weight', + 'vae_decoder.mnn', + 'vae_decoder.mnn.weight', + 'clip_v2.mnn', + 'clip_v2.mnn.weight', + ].forEach(seedFile); else ['unet.bin', 'vae_decoder.bin', 'clip_v2.mnn'].forEach(seedFile); } else { seedFile('model.mlmodelc'); // coreml: a non-empty dir @@ -241,25 +348,41 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * cancelling, which is what native does. */ async generateImageViaUI( - imgOpts: { prompt?: string; backend?: 'mnn' | 'qnn' | 'coreml'; hold?: boolean } = {}, + imgOpts: { + prompt?: string; + backend?: 'mnn' | 'qnn' | 'coreml'; + hold?: boolean; + } = {}, ) { - const { prompt = 'a fox in the snow', backend = 'coreml', hold = false } = imgOpts; + const { + prompt = 'a fox in the snow', + backend = 'coreml', + hold = false, + } = imgOpts; if (!this.view) this.render(); await this.placeImageModel({ backend }); await this.cycleImageMode(); // auto -> ON(force); also activates the downloaded image model await rtl.waitFor(() => { - expect(this.view!.queryByTestId('image-mode-force-badge')).not.toBeNull(); + expect( + this.view!.queryByTestId('image-mode-force-badge'), + ).not.toBeNull(); }); if (hold) boundary.diffusion.holdNextGeneration(); await this.tapSend(prompt); // Native has been entered either way; only the waiting differs. - await rtl.waitFor(() => { expect(boundary.diffusion.calls.generateImage.length).toBe(1); }); + await rtl.waitFor(() => { + expect(boundary.diffusion.calls.generateImage.length).toBe(1); + }); if (hold) { - await rtl.waitFor(() => { expect(boundary.diffusion.generationHeld()).toBe(true); }); + await rtl.waitFor(() => { + expect(boundary.diffusion.generationHeld()).toBe(true); + }); return; } - await rtl.waitFor(() => { expect(this.view!.queryByTestId('generated-image')).not.toBeNull(); }); + await rtl.waitFor(() => { + expect(this.view!.queryByTestId('generated-image')).not.toBeNull(); + }); }, /** @@ -271,19 +394,29 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * A testID on that control would delete this helper. */ async pressImageCardStop() { - type PressNode = { type?: unknown; props?: Record; parent?: PressNode | null }; + type PressNode = { + type?: unknown; + props?: Record; + parent?: PressNode | null; + }; await rtl.act(async () => { const xIcons = this.view!.root.findAll( - (n: PressNode) => n.type === 'Icon' && (n.props as { name?: string })?.name === 'x', + (n: PressNode) => + n.type === 'Icon' && (n.props as { name?: string })?.name === 'x', ); expect(xIcons).toHaveLength(1); let node: PressNode | null = xIcons[0] as unknown as PressNode; for (let depth = 0; node && depth < 12; depth++) { const onPress = node.props?.onPress; - if (typeof onPress === 'function') { (onPress as () => void)(); return; } + if (typeof onPress === 'function') { + (onPress as () => void)(); + return; + } node = node.parent ?? null; } - throw new Error('the image progress card\'s "x" has no pressable ancestor - the stop control is dead'); + throw new Error( + 'the image progress card\'s "x" has no pressable ancestor - the stop control is dead', + ); }); }, @@ -299,23 +432,37 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { // fireEvent.changeText here because once a whisper/STT model is present it silently no-ops on this // TextInput (a real ChatInput coupling: the composer subtree reshapes with voice availability), which // would leave the send button unrendered. Invoking the bound handler is faithful and robust either way. - await rtl.act(async () => { (input as unknown as { props: { onChangeText: (t: string) => void } }).props.onChangeText(text); }); + await rtl.act(async () => { + ( + input as unknown as { props: { onChangeText: (t: string) => void } } + ).props.onChangeText(text); + }); // waitFor the send button (it appears once the text lands), then invoke its TouchableOpacity onPress. // We resolve the handler off the node instead of rtl.fireEvent.press because, once a whisper/STT model // is present, RTL's press traversal does not reach this button's onPress (the composer subtree reshapes // with voice availability) — invoking the bound handler is the same thing a tap does and is robust. await rtl.waitFor(() => view.getByTestId('send-button')); - type PressNode = { props?: Record; parent?: PressNode | null } | null; + type PressNode = { + props?: Record; + parent?: PressNode | null; + } | null; const pressSend = () => { - let n: PressNode = view.getByTestId('send-button') as unknown as PressNode; + let n: PressNode = view.getByTestId( + 'send-button', + ) as unknown as PressNode; for (let d = 0; n && d < 12; d++) { const op = n.props?.onPress; - if (typeof op === 'function') { (op as () => void)(); return; } + if (typeof op === 'function') { + (op as () => void)(); + return; + } n = n.parent ?? null; } rtl.fireEvent.press(view.getByTestId('send-button')); // fallback }; - await rtl.act(async () => { pressSend(); }); + await rtl.act(async () => { + pressSend(); + }); }, /** @@ -325,18 +472,26 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { */ async attachImageViaUI(source: 'library' | 'camera' = 'library') { const view = this.view!; - rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('attach-button'))); - rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('attach-photo'))); + rtl.fireEvent.press( + await rtl.waitFor(() => view.getByTestId('attach-button')), + ); + rtl.fireEvent.press( + await rtl.waitFor(() => view.getByTestId('attach-photo')), + ); // Android: attach-photo opens a "Choose image source" alert — tap "Photo Library" or "Camera" (both // real gestures), which (after a short delay) launches the faked picker and adds the attachment. // The two sources matter for a MULTI-image turn: the faked library returns one fixed uri every time, // so two library picks are indistinguishable from one image arriving twice. The camera returns a // different uri, which is what makes "both images reached the engine" an assertion rather than a hope. rtl.fireEvent.press( - await rtl.waitFor(() => view.getByText(source === 'camera' ? 'Camera' : 'Photo Library')), + await rtl.waitFor(() => + view.getByText(source === 'camera' ? 'Camera' : 'Photo Library'), + ), ); await this.settle(400); // the handler defers pickFromLibrary via setTimeout(300) - await rtl.waitFor(() => { expect(view.queryByTestId('attachments-container')).not.toBeNull(); }); + await rtl.waitFor(() => { + expect(view.queryByTestId('attachments-container')).not.toBeNull(); + }); }, /** @@ -345,9 +500,12 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * sent). NOT settings.updateSettings seeding. */ enableGenerationDetailsViaUI() { - - const { ShowGenerationDetailsToggle } = require('../../src/components/settings/textGenAdvancedSections'); - const s = rtl.render(React.createElement(ShowGenerationDetailsToggle, {})); + const { + ShowGenerationDetailsToggle, + } = require('../../src/components/settings/textGenAdvancedSections'); + const s = rtl.render( + React.createElement(ShowGenerationDetailsToggle, {}), + ); rtl.fireEvent.press(s.getByTestId('show-gen-details-on-button')); s.unmount(); }, @@ -359,42 +517,114 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * TranscriptionModelsTab → the real selectModel sets it active + loads it resident. Requires whisper:true. */ async setupWhisperModel(modelId = 'tiny.en') { - - const { TranscriptionModelsTab } = require('../../src/screens/ModelsScreen/TranscriptionModelsTab'); + const { + TranscriptionModelsTab, + } = require('../../src/screens/ModelsScreen/TranscriptionModelsTab'); const { useWhisperStore } = require('../../src/stores/whisperStore'); - - boundary.fs!.seedFile(`${docs}/whisper-models/ggml-${modelId}.bin`, 75 * 1024 * 1024); + + boundary.fs!.seedFile( + `${docs}/whisper-models/ggml-${modelId}.bin`, + 75 * 1024 * 1024, + ); await useWhisperStore.getState().refreshPresentModels(); // real disk scan → present const t = rtl.render(React.createElement(TranscriptionModelsTab, {})); - await rtl.waitFor(() => { expect(useWhisperStore.getState().presentModelIds).toContain(modelId); }, { timeout: 4000 }); - rtl.fireEvent.press(await rtl.waitFor(() => t.getByTestId('transcription-model-card-0'))); - await rtl.waitFor(() => { expect(useWhisperStore.getState().downloadedModelId).toBe(modelId); }, { timeout: 4000 }); + await rtl.waitFor( + () => { + expect(useWhisperStore.getState().presentModelIds).toContain(modelId); + }, + { timeout: 4000 }, + ); + rtl.fireEvent.press( + await rtl.waitFor(() => t.getByTestId('transcription-model-card-0')), + ); + await rtl.waitFor( + () => { + expect(useWhisperStore.getState().downloadedModelId).toBe(modelId); + }, + { timeout: 4000 }, + ); t.unmount(); }, - /** REAL chat-mode mic gesture: fire the PanResponder grant on the hold-to-talk button (empty input → - * the send button IS the mic in asSendButton mode). onPanResponderGrant → onStartRecording. */ + /** Start a real chat-mode mic gesture. Tests can release it as a hold or keep it pressed. */ async tapMic() { const view = this.view!; - const btn = await rtl.waitFor(() => view.getByTestId('voice-record-button')); + const btn = await rtl.waitFor(() => + view.getByTestId('voice-record-button'), + ); // PanResponder wires onResponderGrant → onPanResponderGrant(evt, gestureState); RNTL fireEvent invokes // the prop directly, so pass a synthetic event carrying a valid touchHistory (PanResponder reads it to // build gestureState). indexOfSingleActiveTouch:-1 = no active bank entry (a fresh grant). const evt = { - nativeEvent: { touches: [], changedTouches: [], identifier: 1, pageX: 0, pageY: 0, timestamp: 0 }, - touchHistory: { touchBank: [], numberActiveTouches: 0, indexOfSingleActiveTouch: -1, mostRecentTimeStamp: 0 }, + nativeEvent: { + touches: [], + changedTouches: [], + identifier: 1, + pageX: 0, + pageY: 0, + timestamp: 0, + }, + touchHistory: { + touchBank: [], + numberActiveTouches: 0, + indexOfSingleActiveTouch: -1, + mostRecentTimeStamp: 0, + }, }; rtl.fireEvent(btn, 'responderGrant', evt); }, - /** REAL hold-to-talk RELEASE: fire the PanResponder release on the mic → onPanResponderRelease → - * onStopRecording (the direct-audio path transcribes the recorded file, the whisper path finalizes). */ + /** One short tap: start recording and lock it until the next tap. */ + async tapMicOnce() { + const view = this.view!; + const btn = await rtl.waitFor(() => + view.getByTestId('voice-record-button'), + ); + const grant = { + nativeEvent: { + touches: [], + changedTouches: [], + identifier: 1, + pageX: 0, + pageY: 0, + timestamp: 0, + }, + touchHistory: { + touchBank: [], + numberActiveTouches: 0, + indexOfSingleActiveTouch: -1, + mostRecentTimeStamp: 0, + }, + }; + const release = { + ...grant, + nativeEvent: { ...grant.nativeEvent, timestamp: 100 }, + }; + rtl.fireEvent(btn, 'responderGrant', grant); + rtl.fireEvent(btn, 'responderRelease', release); + }, + + /** Release after a long press, which stops the recording. */ async releaseMic() { const view = this.view!; - const btn = await rtl.waitFor(() => view.getByTestId('voice-record-button')); + const btn = await rtl.waitFor(() => + view.getByTestId('voice-record-button'), + ); const evt = { - nativeEvent: { touches: [], changedTouches: [], identifier: 1, pageX: 0, pageY: 0, timestamp: 0 }, - touchHistory: { touchBank: [], numberActiveTouches: 0, indexOfSingleActiveTouch: -1, mostRecentTimeStamp: 0 }, + nativeEvent: { + touches: [], + changedTouches: [], + identifier: 1, + pageX: 0, + pageY: 0, + timestamp: 500, + }, + touchHistory: { + touchBank: [], + numberActiveTouches: 0, + indexOfSingleActiveTouch: -1, + mostRecentTimeStamp: 500, + }, }; rtl.fireEvent(btn, 'responderRelease', evt); }, @@ -408,22 +638,43 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { */ async enterVoiceMode() { const view = this.view!; - + const { useTTSStore } = require('@offgrid/pro/audio/ttsStore'); const engineId = useTTSStore.getState().settings.engineId; // BOUNDARY: the persisted artifact a completed voice-model download leaves — drives shouldLoad in the // REAL KokoroTTSBridge. Set via the real store action (like the LLM's @local_llm/downloaded_models // record). NOT a phase/isReady poke: readiness below is EMERGENT from the real engine + executorch fake. - await useTTSStore.getState().updateSettings({ modelDownloaded: { ...(useTTSStore.getState().settings.modelDownloaded ?? {}), [engineId]: true } }); + await useTTSStore + .getState() + .updateSettings({ + modelDownloaded: { + ...(useTTSStore.getState().settings.modelDownloaded ?? {}), + [engineId]: true, + }, + }); // The real EngineBridge (mounted in render()) now mounts KokoroTTSBridge → the executorch fake reports // isReady → KokoroEngine._setBridge → phase 'ready'. Wait for that emergent readiness (the same signal // the real Voice toggle gates on) — never set by the test. - await rtl.waitFor(() => { expect(useTTSStore.getState().isReady).toBe(true); }, { timeout: 4000 }); + await rtl.waitFor( + () => { + expect(useTTSStore.getState().isReady).toBe(true); + }, + { timeout: 4000 }, + ); // GESTURE: open the chat-input quick-settings popover and tap the Voice row (the alternate real entry // to voice mode, per the header dropdown). initializeEngine + interfaceMode='audio' run for real. - rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('quick-settings-button'))); - rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('quick-tts-mode'))); - await rtl.waitFor(() => { expect(view.getByTestId('voice-record-button-audio')).toBeTruthy(); }, { timeout: 4000 }); + rtl.fireEvent.press( + await rtl.waitFor(() => view.getByTestId('quick-settings-button')), + ); + rtl.fireEvent.press( + await rtl.waitFor(() => view.getByTestId('quick-tts-mode')), + ); + await rtl.waitFor( + () => { + expect(view.getByTestId('voice-record-button-audio')).toBeTruthy(); + }, + { timeout: 4000 }, + ); }, /** @@ -432,11 +683,28 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * driving the real transcribeFile → onTranscript → send path (the working voice-mode STT pipeline). Pass * `scripted` for a text reply; omit it for an image request (the diffusion boundary renders the image). */ - async voiceSend(transcript: string, scripted?: { text?: string; content?: string; toolCalls?: Array<{ name: string; arguments: Record }> }) { + async voiceSend( + transcript: string, + scripted?: { + text?: string; + content?: string; + toolCalls?: Array<{ name: string; arguments: Record }>; + }, + ) { const view = this.view!; if (scripted) { - if (opts.engine === 'llama') boundary.llama!.scriptCompletion(scripted as { text?: string }); - else boundary.litert.scriptTurn(scripted as { content?: string; toolCalls?: Array<{ name: string; arguments: Record }> }); + if (opts.engine === 'llama') + boundary.llama!.scriptCompletion(scripted as { text?: string }); + else + boundary.litert.scriptTurn( + scripted as { + content?: string; + toolCalls?: Array<{ + name: string; + arguments: Record; + }>; + }, + ); } // BOUNDARY: the whisper model transcribes the recorded audio file to this text. boundary.whisper!.setFileTranscript(transcript); @@ -449,13 +717,17 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { /** Mount the real ChatScreen (plus the real app.root slot when pro is active, so the TTS EngineBridge * mounts and the voice engine can load over the executorch fake — the same slot App.tsx renders). */ render() { - const { ChatScreen } = require('../../src/screens/ChatScreen'); const { getSlot, SLOTS } = require('../../src/bootstrap/slotRegistry'); - + const AppRoot = opts.pro ? getSlot(SLOTS.appRoot) : undefined; const tree = AppRoot - ? React.createElement(React.Fragment, null, React.createElement(AppRoot, {}), React.createElement(ChatScreen, {})) + ? React.createElement( + React.Fragment, + null, + React.createElement(AppRoot, {}), + React.createElement(ChatScreen, {}), + ) : React.createElement(ChatScreen, {}); this.view = rtl.render(tree); return this.view; @@ -466,9 +738,26 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * send button, and await the assistant reply rendering. `scripted` is what the (faked) native engine * returns — the real generation pipeline turns it into the rendered bubble. */ - async send(text: string, scripted: { text?: string; content?: string; reasoning?: string; thinkingText?: string; toolCalls?: unknown[]; completionMeta?: CompletionMeta }) { - if (opts.engine === 'llama') boundary.llama!.scriptCompletion(scripted as { text?: string }); - else boundary.litert.scriptTurn(scripted as { content?: string; toolCalls?: { name: string; arguments: Record }[] }); + async send( + text: string, + scripted: { + text?: string; + content?: string; + reasoning?: string; + thinkingText?: string; + toolCalls?: unknown[]; + completionMeta?: CompletionMeta; + }, + ) { + if (opts.engine === 'llama') + boundary.llama!.scriptCompletion(scripted as { text?: string }); + else + boundary.litert.scriptTurn( + scripted as { + content?: string; + toolCalls?: { name: string; arguments: Record }[]; + }, + ); const view = this.view!; const input = await rtl.waitFor(() => view.getByTestId('chat-input')); @@ -476,23 +765,37 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { // fireEvent.changeText here because once a whisper/STT model is present it silently no-ops on this // TextInput (a real ChatInput coupling: the composer subtree reshapes with voice availability), which // would leave the send button unrendered. Invoking the bound handler is faithful and robust either way. - await rtl.act(async () => { (input as unknown as { props: { onChangeText: (t: string) => void } }).props.onChangeText(text); }); + await rtl.act(async () => { + ( + input as unknown as { props: { onChangeText: (t: string) => void } } + ).props.onChangeText(text); + }); // waitFor the send button (it appears once the text lands), then invoke its TouchableOpacity onPress. // We resolve the handler off the node instead of rtl.fireEvent.press because, once a whisper/STT model // is present, RTL's press traversal does not reach this button's onPress (the composer subtree reshapes // with voice availability) — invoking the bound handler is the same thing a tap does and is robust. await rtl.waitFor(() => view.getByTestId('send-button')); - type PressNode = { props?: Record; parent?: PressNode | null } | null; + type PressNode = { + props?: Record; + parent?: PressNode | null; + } | null; const pressSend = () => { - let n: PressNode = view.getByTestId('send-button') as unknown as PressNode; + let n: PressNode = view.getByTestId( + 'send-button', + ) as unknown as PressNode; for (let d = 0; n && d < 12; d++) { const op = n.props?.onPress; - if (typeof op === 'function') { (op as () => void)(); return; } + if (typeof op === 'function') { + (op as () => void)(); + return; + } n = n.parent ?? null; } rtl.fireEvent.press(view.getByTestId('send-button')); // fallback }; - await rtl.act(async () => { pressSend(); }); + await rtl.act(async () => { + pressSend(); + }); }, /** @@ -501,27 +804,47 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * - 'dots' → tap the 3-dots '•••' button in the message meta row * BOTH are real user entry points and must both be exercised (they wire the same setShowActionMenu). */ - async openActionMenu(role: 'user' | 'assistant', via: 'longpress' | 'dots') { + async openActionMenu( + role: 'user' | 'assistant', + via: 'longpress' | 'dots', + ) { const view = this.view!; const testId = role === 'user' ? 'user-message' : 'assistant-message'; - const bubbles = await rtl.waitFor(() => { const b = view.queryAllByTestId(testId); expect(b.length).toBeGreaterThan(0); return b; }); + const bubbles = await rtl.waitFor(() => { + const b = view.queryAllByTestId(testId); + expect(b.length).toBeGreaterThan(0); + return b; + }); const target = bubbles[bubbles.length - 1]; if (via === 'longpress') { rtl.fireEvent(target, 'longPress'); } else { // The 3-dots '•••' lives inside THIS message's element — scope to it (not the global-last dots, // which would be a different message's button). - const dots = await rtl.waitFor(() => rtl.within(target).getByText('•••')); + const dots = await rtl.waitFor(() => + rtl.within(target).getByText('•••'), + ); rtl.fireEvent.press(dots); } - await rtl.waitFor(() => { expect(view.getByTestId('action-menu')).toBeTruthy(); }); + await rtl.waitFor(() => { + expect(view.getByTestId('action-menu')).toBeTruthy(); + }); }, /** * REAL regenerate gesture: open the action menu (via long-press OR 3-dots) and press "Retry". */ - async regenerateLast(scripted: { text?: string; content?: string; reasoning?: string; toolCalls?: unknown[] }, via: 'longpress' | 'dots' = 'longpress') { - if (opts.engine === 'llama') boundary.llama!.scriptCompletion(scripted as { text?: string }); + async regenerateLast( + scripted: { + text?: string; + content?: string; + reasoning?: string; + toolCalls?: unknown[]; + }, + via: 'longpress' | 'dots' = 'longpress', + ) { + if (opts.engine === 'llama') + boundary.llama!.scriptCompletion(scripted as { text?: string }); else boundary.litert.scriptTurn(scripted as { content?: string }); await this.openActionMenu('assistant', via); rtl.fireEvent.press(this.view!.getByTestId('action-retry')); @@ -531,13 +854,20 @@ export async function setupChatScreen(opts: ChatHarnessOptions) { * REAL edit gesture: open the action menu (via long-press OR 3-dots) → "Edit" → change text → * "SAVE & RESEND". The real edit handler rewrites history and re-runs generation. */ - async editLastUserMessage(newText: string, scripted: { text?: string; content?: string }, via: 'longpress' | 'dots' = 'longpress') { - if (opts.engine === 'llama') boundary.llama!.scriptCompletion(scripted as { text?: string }); + async editLastUserMessage( + newText: string, + scripted: { text?: string; content?: string }, + via: 'longpress' | 'dots' = 'longpress', + ) { + if (opts.engine === 'llama') + boundary.llama!.scriptCompletion(scripted as { text?: string }); else boundary.litert.scriptTurn(scripted as { content?: string }); await this.openActionMenu('user', via); const view = this.view!; rtl.fireEvent.press(view.getByTestId('action-edit')); - const input = await rtl.waitFor(() => view.getByPlaceholderText('Enter message...')); + const input = await rtl.waitFor(() => + view.getByPlaceholderText('Enter message...'), + ); rtl.fireEvent.changeText(input, newText); rtl.fireEvent.press(view.getByText('SAVE & RESEND')); }, diff --git a/__tests__/harness/nativeBoundary.ts b/__tests__/harness/nativeBoundary.ts index 0d0c7581e..417269d89 100644 --- a/__tests__/harness/nativeBoundary.ts +++ b/__tests__/harness/nativeBoundary.ts @@ -30,6 +30,13 @@ import { type NativeFileSystemBoundary, } from './nativeFileSystem'; +// The hosted serial coverage gate loads more than 600 suites into one process. Near the end of that +// run, instrumentation and garbage collection can delay any real native-boundary journey beyond the +// normal ceiling even when it finishes in seconds alone. Keep every behavior wait strict, but give +// the CI process one shared outer budget instead of adding per-suite exceptions as random suites +// cross the default under load. +jest.setTimeout(process.env.CI === 'true' ? 90_000 : 30_000); + // --------------------------------------------------------------------------- // Fake: LiteRTModule (Android litert engine). Destructured at import in src/services/litert.ts. // A driveable event emitter + arg-recording methods. Native events: litert_token/thinking/complete/ @@ -359,14 +366,14 @@ export interface LlamaFake { multimodalHoldActive(): boolean; /** react-native module object to inject for 'llama.rn'. */ module: Record; - calls: { completion: unknown[][] }; + calls: { completion: unknown[][]; clearCache: boolean[] }; } function makeLlamaFake( onRelease?: () => void, chatTemplate?: string, ): LlamaFake { - const calls: LlamaFake['calls'] = { completion: [] }; + const calls: LlamaFake['calls'] = { completion: [], clearCache: [] }; type PreparedCompletion = Omit & { text: string; }; @@ -530,6 +537,9 @@ function makeLlamaFake( releaseFn = null; f?.(); // release a held mid-stream pause so the abort lands }), + clearCache: jest.fn(async (clearData: boolean = false) => { + calls.clearCache.push(clearData); + }), // Releasing the native context frees its memory — but the OS reclaims it SHORTLY AFTER release() // returns (device-faithful), not synchronously. Defer the free so the reclaim barrier captures the // still-high footprint as its baseline and then observes the drop on a later poll (as on device). diff --git a/__tests__/integration/app/bootNotBlockedByDownloadDb.rendered.test.tsx b/__tests__/integration/app/bootNotBlockedByDownloadDb.rendered.test.tsx index 85c71fa26..7057601b5 100644 --- a/__tests__/integration/app/bootNotBlockedByDownloadDb.rendered.test.tsx +++ b/__tests__/integration/app/bootNotBlockedByDownloadDb.rendered.test.tsx @@ -12,26 +12,37 @@ * artifact: the boot loader ('app-loading') CLEARS anyway. RED on HEAD: the loader stays * forever because initializeApp awaits hydrateDownloadStore/reattach before first paint. */ -import { installNativeBoundary, requireRTL } from '../../harness/nativeBoundary'; +import { + installNativeBoundary, + requireRTL, +} from '../../harness/nativeBoundary'; -jest.mock('react-native-bootsplash', () => ({ hide: jest.fn(async () => {}) }), { virtual: true }); +jest.mock( + 'react-native-bootsplash', + () => ({ hide: jest.fn(async () => {}) }), + { virtual: true }, +); describe('app boot is not blocked by the download DB (rendered)', () => { - it('clears the boot loader while getActiveDownloads never resolves (wedged download DB)', async () => { - const boundary = installNativeBoundary(); + it('clears the boot loader while getActiveDownloads remains wedged', async () => { + installNativeBoundary(); - const React = require('react'); const rtl = requireRTL(); const { NativeModules } = require('react-native'); - // WEDGE the download DB: the native read never resolves (the device's 9-writer contention, - // taken to the limit). Everything else on the boundary behaves normally. + const AsyncStorage = require('@react-native-async-storage/async-storage'); + await AsyncStorage.clear(); + // WEDGE the download DB through first paint. The boundary is released only after the visible + // boot outcome is proved, so the test remains faithful without leaking an immortal Promise. + let releaseDownloadDb!: (rows: unknown[]) => void; + const pendingDownloadDb = new Promise(resolve => { + releaseDownloadDb = resolve; + }); NativeModules.DownloadManagerModule = { ...NativeModules.DownloadManagerModule, - getActiveDownloads: jest.fn(() => new Promise(() => {})), + getActiveDownloads: jest.fn(() => pendingDownloadDb), }; const App = require('../../../App').default; - const view = rtl.render(React.createElement(App)); @@ -39,9 +50,26 @@ describe('app boot is not blocked by the download DB (rendered)', () => { expect(view.queryByTestId('app-loading')).not.toBeNull(); // Terminal artifact: the loader clears even though the download DB never answered. - await rtl.waitFor(() => { expect(view.queryByTestId('app-loading')).toBeNull(); }, { timeout: 8000 }); + await rtl.waitFor( + () => { + expect(view.queryByTestId('app-loading')).toBeNull(); + }, + // Preserve the responsive local contract. Serial hosted coverage can pause + // the shared process for GC, so it receives only a load-tolerant assertion + // budget; nativeBoundary still owns the complete CI test ceiling. + { timeout: process.env.CI === 'true' ? 45_000 : 8_000 }, + ); + + await rtl.act(async () => { + releaseDownloadDb([]); + await pendingDownloadDb; + }); + await rtl.waitFor(() => { + expect( + NativeModules.DownloadManagerModule.getActiveDownloads, + ).toHaveBeenCalledTimes(2); + }); view.unmount(); - void boundary; - }, 20000); + }); }); diff --git a/__tests__/integration/audio/chatMicTapToggle.rendered.redflow.test.tsx b/__tests__/integration/audio/chatMicTapToggle.rendered.redflow.test.tsx new file mode 100644 index 000000000..838a987aa --- /dev/null +++ b/__tests__/integration/audio/chatMicTapToggle.rendered.redflow.test.tsx @@ -0,0 +1,56 @@ +/** + * A chat mic supports both paths from the same control: one short tap keeps the + * recording open, and the next short tap stops it. This mounts the real chat, + * recorder controller, Whisper service, and composer. Only native device leaves + * are faked by the shared harness. + */ +import { setupChatScreen } from '../../harness/chatHarness'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: () => {}, + goBack: () => {}, + setOptions: () => {}, + addListener: () => () => {}, + }), + useRoute: () => require('../../harness/chatHarness').routeHolder, + useFocusEffect: () => {}, + useIsFocused: () => true, +})); + +describe('chat mic tap-to-record', () => { + it('keeps recording after one tap and stops on the next tap', async () => { + const h = await setupChatScreen({ + engine: 'llama', + platform: 'android', + whisper: true, + }); + await h.setupWhisperModel('tiny.en'); + h.render(); + + await h.tapMicOnce(); + await h.rtl.waitFor(() => { + expect(h.boundary.whisper!.realtimeActive()).toBe(true); + expect(h.view!.getByText('Tap mic to stop')).toBeTruthy(); + }); + + await h.tapMicOnce(); + await h.rtl.waitFor( + () => { + expect(h.boundary.whisper!.realtimeActive()).toBe(false); + expect(h.view!.queryByText('Tap mic to stop')).toBeNull(); + }, + { timeout: 4000 }, + ); + + h.boundary.whisper!.emitRealtime({ + text: 'tap recording works', + isCapturing: false, + }); + await h.rtl.waitFor(() => { + expect(h.view!.getByTestId('chat-input').props.value).toContain( + 'tap recording works', + ); + }); + }, 30000); +}); diff --git a/__tests__/integration/audio/selectedWhisperModelLoadsBeforeTranscription.rendered.redflow.test.tsx b/__tests__/integration/audio/selectedWhisperModelLoadsBeforeTranscription.rendered.redflow.test.tsx new file mode 100644 index 000000000..892bdb20c --- /dev/null +++ b/__tests__/integration/audio/selectedWhisperModelLoadsBeforeTranscription.rendered.redflow.test.tsx @@ -0,0 +1,74 @@ +/** + * Device regression: downloading a new transcription model selects it, but an older Whisper + * context can still be resident. Starting dictation must replace that context before capture; + * "some Whisper model is loaded" is not enough. + * + * Real TranscriptionModelsTab + ChatScreen + stores + residency + Whisper service. Only the + * filesystem, download manager, and whisper.rn runtime are device-boundary fakes. + */ +import { setupChatScreen } from '../../harness/chatHarness'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }), + useRoute: () => require('../../harness/chatHarness').routeHolder, + useFocusEffect: () => {}, + useIsFocused: () => true, +})); + +describe('selected Whisper model identity', () => { + it('loads the newly downloaded model before the next transcription', async () => { + const h = await setupChatScreen({ engine: 'llama', whisper: true, download: true }); + await h.setupWhisperModel('tiny.en'); + + const React = require('react'); + const { TranscriptionModelsTab } = require('../../../src/screens/ModelsScreen/TranscriptionModelsTab'); + const { useWhisperStore } = require('../../../src/stores/whisperStore'); + const { whisperService } = require('../../../src/services/whisperService'); + const modelTab = h.rtl.render(React.createElement(TranscriptionModelsTab)); + + // Large v3 Turbo is catalogue index 8. Download it through the real model-card action. + await h.rtl.act(async () => { + h.rtl.fireEvent.press(modelTab.getByTestId('transcription-model-card-8-download')); + await Promise.resolve(); + }); + await h.rtl.waitFor(() => { expect(h.boundary.download!.active()).toHaveLength(1); }); + const row = h.boundary.download!.active()[0]; + await h.rtl.act(async () => { await Promise.resolve(); }); + await h.rtl.act(async () => { + h.boundary.fs!.seedFile( + '/docs/whisper-models/ggml-large-v3-turbo.bin', + 809 * 1024 * 1024, + ); + h.boundary.download!.events.emit('DownloadComplete', { + downloadId: row.downloadId, + fileName: row.fileName, + modelId: row.modelId, + bytesDownloaded: row.totalBytes ?? 1, + totalBytes: row.totalBytes ?? 1, + status: 'completed', + localUri: '/docs/whisper-models/ggml-large-v3-turbo.bin', + }); + }); + await h.rtl.waitFor(() => { + expect(useWhisperStore.getState().downloadedModelId).toBe('large-v3-turbo'); + }); + modelTab.unmount(); + + // The old tiny.en context is still resident. A real mic gesture must replace it with + // Large v3 Turbo before whisper.rn starts capturing. + h.render(); + await h.tapMic(); + await h.rtl.waitFor(() => { + expect(h.boundary.whisper!.hasRealtimeSubscriber()).toBe(true); + }, { timeout: 4000 }); + + const initCalls = h.boundary.whisper!.module.initWhisper.mock.calls; + const lastLoadedPath = initCalls[initCalls.length - 1]?.[0]?.filePath; + await h.rtl.act(async () => { + h.boundary.whisper!.emitRealtime({ text: 'test', isCapturing: false }); + await whisperService.stopTranscription(); + }); + + expect(lastLoadedPath).toBe('/docs/whisper-models/ggml-large-v3-turbo.bin'); + }, 30000); +}); diff --git a/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx b/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx index 1effa4f4b..84538f577 100644 --- a/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx +++ b/__tests__/integration/audio/whisperRealtimeBlockedRecovers.redflow.test.tsx @@ -77,5 +77,13 @@ describe('realtime hold-to-talk dictation recovers when whisper load is blocked await h.rtl.waitFor(() => { expect(view.getByTestId('chat-input').props.value ?? '').toContain('take a note'); }, { timeout: 4000 }); + + // End the native recording session before Jest tears down the React Native module graph. + // The screen cleanup is intentionally fire-and-forget in production, but this integration + // journey must wait for the boundary cleanup so no native promise crosses test environments. + const { whisperService } = require('../../../src/services/whisperService'); + await h.rtl.act(async () => { + await whisperService.forceReset(); + }); }); }); diff --git a/__tests__/integration/audio/whisperStartSupersededNoGhost.redflow.test.tsx b/__tests__/integration/audio/whisperStartSupersededNoGhost.redflow.test.tsx index 75317e10d..0f10cd434 100644 --- a/__tests__/integration/audio/whisperStartSupersededNoGhost.redflow.test.tsx +++ b/__tests__/integration/audio/whisperStartSupersededNoGhost.redflow.test.tsx @@ -55,5 +55,6 @@ describe('realtime dictation: releasing the mic during model load starts NO ghos // realtimeActive() true. expect(h.boundary.whisper!.realtimeActive()).toBe(false); expect(h.boundary.whisper!.hasRealtimeSubscriber()).toBe(false); + expect(h.view!.queryByText('Transcribing...')).toBeNull(); }, 30000); }); diff --git a/__tests__/integration/chat/existingConversationVisibleImmediately.rendered.redflow.test.tsx b/__tests__/integration/chat/existingConversationVisibleImmediately.rendered.redflow.test.tsx new file mode 100644 index 000000000..84d0f068b --- /dev/null +++ b/__tests__/integration/chat/existingConversationVisibleImmediately.rendered.redflow.test.tsx @@ -0,0 +1,53 @@ +import { setupChatScreen } from '../../harness/chatHarness'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: () => {}, + goBack: () => {}, + setOptions: () => {}, + addListener: () => () => {}, + }), + useRoute: () => require('../../harness/chatHarness').routeHolder, + useFocusEffect: () => {}, + useIsFocused: () => true, +})); + +describe('opening an existing Mobile chat', () => { + it('shows stored messages before the list completes its first measurement', async () => { + const h = await setupChatScreen({ engine: 'llama', platform: 'ios' }); + h.render(); + await h.send('Show this chat again', { text: 'The stored reply is ready.' }); + await h.rtl.waitFor(() => { + expect(h.view!.getByText('The stored reply is ready.')).toBeVisible(); + }); + + const conversationId = h.conversationId; + expect(conversationId).not.toBeNull(); + h.view!.unmount(); + + require('../../harness/chatHarness').routeHolder.params = { + conversationId, + }; + // Hold layout work until after the assertion. This proves that stored content + // is visible on the first render and does not depend on a deferred frame. + const deferredFrames: Array<(time: number) => void> = []; + const originalRequestAnimationFrame = (globalThis as any).requestAnimationFrame; + (globalThis as any).requestAnimationFrame = (callback: (time: number) => void) => { + deferredFrames.push(callback); + return deferredFrames.length; + }; + + try { + const reopened = h.render(); + await h.rtl.waitFor(() => { + expect(reopened.getByText('The stored reply is ready.')).toBeVisible(); + }); + expect(reopened.getByTestId('chat-message-list')).toBeVisible(); + } finally { + (globalThis as any).requestAnimationFrame = originalRequestAnimationFrame; + await h.rtl.act(async () => { + deferredFrames.forEach(callback => callback(Date.now())); + }); + } + }); +}); diff --git a/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts b/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts index 7dce768a2..eac4062ba 100644 --- a/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts +++ b/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts @@ -48,6 +48,7 @@ describe('chat-mode STT is dictation-to-the-input-box on every engine (LiteRT to const transcriptArgs: string[] = []; const { result } = renderHook(() => useVoiceInput({ conversationId: 'c1', + interfaceMode: 'chat', onTranscript: (t: string) => { transcriptArgs.push(t); }, onAutoSend: (...a: unknown[]) => { autoSendArgs.push(a); }, onAudioAttachment: (p: Record) => { attachmentArgs.push(p); }, diff --git a/__tests__/integration/generation/remoteModelIndicator.rendered.happy.test.tsx b/__tests__/integration/generation/remoteModelIndicator.rendered.happy.test.tsx index abbbd6fae..2294cb01c 100644 --- a/__tests__/integration/generation/remoteModelIndicator.rendered.happy.test.tsx +++ b/__tests__/integration/generation/remoteModelIndicator.rendered.happy.test.tsx @@ -17,10 +17,11 @@ import { render, fireEvent, waitFor } from '@testing-library/react-native'; jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }), + useRoute: () => ({ params: undefined }), useIsFocused: () => true, useFocusEffect: () => {}, })); -import { RemoteServersScreen } from '../../../src/screens/RemoteServersScreen'; +import { RemoteServerEditorScreen } from '../../../src/screens/RemoteServerEditorScreen'; import { ModelSelectorModal } from '../../../src/components/ModelSelectorModal'; import { useRemoteServerStore } from '../../../src/stores'; @@ -48,9 +49,8 @@ describe('T053 (rendered) — remote model is marked in the selector (cloud/Remo // Real gesture: add a remote server via the modal (T046 flow) → real addServer + testConnection // populate the store (serverHealth healthy + discoveredModels from /v1/models). - const servers = render(); - fireEvent.press(servers.getByTestId('add-server')); - fireEvent.changeText(await waitFor(() => servers.getByPlaceholderText('e.g., Off Grid AI Desktop')), 'My LM Studio'); + const servers = render(); + fireEvent.changeText(await waitFor(() => servers.getByPlaceholderText('Off Grid AI Desktop')), 'My LM Studio'); fireEvent.changeText(servers.getByPlaceholderText('http://192.168.1.50:7878'), 'http://localhost:1234'); fireEvent.press(servers.getByTestId('test-connection')); await waitFor(() => { expect(servers.queryByText(/Connected \(/)).not.toBeNull(); }, { timeout: 4000 }); diff --git a/__tests__/integration/generation/remoteServerConnect.rendered.happy.test.tsx b/__tests__/integration/generation/remoteServerConnect.rendered.happy.test.tsx index 72215514d..e9c594f8b 100644 --- a/__tests__/integration/generation/remoteServerConnect.rendered.happy.test.tsx +++ b/__tests__/integration/generation/remoteServerConnect.rendered.happy.test.tsx @@ -15,10 +15,12 @@ import { render, fireEvent, waitFor } from '@testing-library/react-native'; jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }), + useRoute: () => ({ params: undefined }), useIsFocused: () => true, useFocusEffect: () => {}, })); import { RemoteServersScreen } from '../../../src/screens/RemoteServersScreen'; +import { RemoteServerEditorScreen } from '../../../src/screens/RemoteServerEditorScreen'; import { useRemoteServerStore } from '../../../src/stores'; describe('T046 (rendered) — add a remote server → it connects (connected state renders)', () => { @@ -35,13 +37,10 @@ describe('T046 (rendered) — add a remote server → it connects (connected sta }); it('shows the server as Connected after adding it via the modal', async () => { - const ui = render(); - - // Real gesture: open the Add Server modal (the screen's Add Server button). - fireEvent.press(ui.getByTestId('add-server')); + const ui = render(); // Fill the real modal inputs (targeted by their placeholders). - fireEvent.changeText(await waitFor(() => ui.getByPlaceholderText('e.g., Off Grid AI Desktop')), 'My LM Studio'); + fireEvent.changeText(await waitFor(() => ui.getByPlaceholderText('Off Grid AI Desktop')), 'My LM Studio'); fireEvent.changeText(ui.getByPlaceholderText('http://192.168.1.50:7878'), 'http://localhost:1234'); // Tap Test Connection → the real probe runs over the faked /v1/models. The Save button stays disabled @@ -53,7 +52,10 @@ describe('T046 (rendered) — add a remote server → it connects (connected sta fireEvent.press(ui.getByTestId('save-server')); // Back on the screen: the server row appears and its status shows Connected (real addServer + testConnection). - await waitFor(() => { expect(ui.queryByText('My LM Studio')).not.toBeNull(); }, { timeout: 4000 }); - await waitFor(() => { expect(ui.queryByText('Connected')).not.toBeNull(); }, { timeout: 4000 }); + await waitFor(() => { expect(useRemoteServerStore.getState().servers).toHaveLength(1); }, { timeout: 4000 }); + ui.unmount(); + const list = render(); + await waitFor(() => { expect(list.queryByText('My LM Studio')).not.toBeNull(); }, { timeout: 4000 }); + await waitFor(() => { expect(list.queryByText('Connected')).not.toBeNull(); }, { timeout: 4000 }); }); }); diff --git a/__tests__/integration/generation/remoteToolCapabilityPreflight.test.ts b/__tests__/integration/generation/remoteToolCapabilityPreflight.test.ts new file mode 100644 index 000000000..87de665b2 --- /dev/null +++ b/__tests__/integration/generation/remoteToolCapabilityPreflight.test.ts @@ -0,0 +1,40 @@ +import { OpenAICompatibleProvider } from '../../../src/services/providers/openAICompatibleProvider'; +import { providerRegistry } from '../../../src/services/providers'; +import { + REMOTE_TOOLS_UNAVAILABLE, + remoteToolCapabilityIssue, +} from '../../../src/services/toolCapabilityPreflight'; +import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; + +describe('remote Chat tool capability preflight', () => { + afterEach(() => { + useRemoteServerStore.setState({ activeServerId: null }); + providerRegistry.unregisterProvider('no-tools'); + providerRegistry.unregisterProvider('with-tools'); + }); + + it('stops a tool turn before selection when the active remote model cannot call tools', () => { + const provider = new OpenAICompatibleProvider('no-tools', { + endpoint: 'http://remote.example', + modelId: 'ui-tars', + }); + provider.updateCapabilities({ supportsToolCalling: false }); + providerRegistry.registerProvider('no-tools', provider); + useRemoteServerStore.setState({ activeServerId: 'no-tools' }); + + expect(remoteToolCapabilityIssue(2)).toBe(REMOTE_TOOLS_UNAVAILABLE); + }); + + it('allows ordinary chat and a tool turn with a capable remote model', () => { + const provider = new OpenAICompatibleProvider('with-tools', { + endpoint: 'http://remote.example', + modelId: 'planner', + }); + provider.updateCapabilities({ supportsToolCalling: true }); + providerRegistry.registerProvider('with-tools', provider); + useRemoteServerStore.setState({ activeServerId: 'with-tools' }); + + expect(remoteToolCapabilityIssue(0)).toBeUndefined(); + expect(remoteToolCapabilityIssue(2)).toBeUndefined(); + }); +}); diff --git a/__tests__/integration/generation/toolExtensionLoop.test.ts b/__tests__/integration/generation/toolExtensionLoop.test.ts index a921cf6fe..ccd1f07cb 100644 --- a/__tests__/integration/generation/toolExtensionLoop.test.ts +++ b/__tests__/integration/generation/toolExtensionLoop.test.ts @@ -144,7 +144,10 @@ describe('tool extension loop integration', () => { expect(executorMock).toHaveBeenCalledTimes(1); expect(executorMock).toHaveBeenCalledWith( - expect.objectContaining({ name: MCP_TOOL_NAME }), + expect.objectContaining({ + name: MCP_TOOL_NAME, + context: { conversationId: ctx.conversationId }, + }), ); const { executeToolCall } = require('../../../src/services/tools'); diff --git a/__tests__/integration/happy/editMessage.happy.test.tsx b/__tests__/integration/happy/editMessage.happy.test.tsx index 7bf95266e..56b428257 100644 --- a/__tests__/integration/happy/editMessage.happy.test.tsx +++ b/__tests__/integration/happy/editMessage.happy.test.tsx @@ -9,7 +9,12 @@ import { setupChatScreen } from '../../harness/chatHarness'; jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }), + useNavigation: () => ({ + navigate: () => {}, + goBack: () => {}, + setOptions: () => {}, + addListener: () => () => {}, + }), useRoute: () => require('../../harness/chatHarness').routeHolder, useFocusEffect: () => {}, useIsFocused: () => true, @@ -18,15 +23,32 @@ jest.mock('@react-navigation/native', () => ({ describe('happy — edit a message via the real action menu (heavy entry point)', () => { // The action menu opens TWO ways — long-press the bubble AND the 3-dots '•••' button. Both are real // user entry points, so the edit flow is validated through each. - it.each(['longpress', 'dots'] as const)('%s → Edit → change text → SAVE & RESEND re-runs generation', async (via) => { - const h = await setupChatScreen({ engine: 'litert' }); - h.render(); + it.each(['longpress', 'dots'] as const)( + '%s → Edit → change text → SAVE & RESEND re-runs generation', + async via => { + const h = await setupChatScreen({ engine: 'litert' }); + h.render(); - await h.send('what is the capital of span', { content: 'The capital of Spain is Madrid.' }); - await h.rtl.waitFor(() => { expect(h.view!.queryByText(/The capital of Spain is Madrid\./)).not.toBeNull(); }); + await h.send('what is the capital of span', { + content: 'The capital of Spain is Madrid.', + }); + await h.rtl.waitFor(() => { + expect( + h.view!.queryByText(/The capital of Spain is Madrid\./), + ).not.toBeNull(); + }); - // User fixes the typo and resends via the real Edit gesture (opened via this affordance). - await h.editLastUserMessage('what is the capital of Spain', { content: 'Madrid is the capital of Spain.' }, via); - await h.rtl.waitFor(() => { expect(h.view!.queryByText(/Madrid is the capital of Spain\./)).not.toBeNull(); }); - }); + // User fixes the typo and resends via the real Edit gesture (opened via this affordance). + await h.editLastUserMessage( + 'what is the capital of Spain', + { content: 'Madrid is the capital of Spain.' }, + via, + ); + await h.rtl.waitFor(() => { + expect( + h.view!.queryByText(/Madrid is the capital of Spain\./), + ).not.toBeNull(); + }); + }, + ); }); diff --git a/__tests__/integration/happy/firstMessage.happy.test.tsx b/__tests__/integration/happy/firstMessage.happy.test.tsx index 88c2233a1..87d3a3ab1 100644 --- a/__tests__/integration/happy/firstMessage.happy.test.tsx +++ b/__tests__/integration/happy/firstMessage.happy.test.tsx @@ -22,6 +22,7 @@ describe('happy — first message renders the answer (heavy entry point)', () => h.render(); await h.send('what is the capital of France', { text: 'The capital of France is Paris.' }); await h.rtl.waitFor(() => { expect(h.view!.queryByText(/The capital of France is Paris\./)).not.toBeNull(); }); + expect(h.boundary.llama!.calls.clearCache).toContain(true); }); it('LiteRT: typing + send renders the reply', async () => { @@ -39,5 +40,58 @@ describe('happy — first message renders the answer (heavy entry point)', () => h.render(); await h.send('what is the capital of France', { text: 'The capital of France is Paris.' }); await h.rtl.waitFor(() => { expect(h.view!.queryByText(/The capital of France is Paris\./)).not.toBeNull(); }); + expect(h.boundary.llama!.calls.clearCache).toContain(true); + }); + + it('new chat starts the selected model load and shows the real loading state', async () => { + const h = await setupChatScreen({ engine: 'llama', platform: 'ios', deferInitialLoad: true }); + h.boundary.llama!.scriptMultimodalHold(); + h.render(); + + await h.rtl.waitFor(() => { + expect(h.boundary.llama!.multimodalHoldActive()).toBe(true); + expect(h.view!.queryByText(/Loading Test Model/)).not.toBeNull(); + }); + + h.boundary.llama!.releaseMultimodalHold(); + await h.rtl.waitFor(() => { + expect(h.view!.queryByText(/Loading Test Model/)).toBeNull(); + }, { timeout: 5000 }); + }); + + it('new chat keeps a remote model choice while discovery metadata refreshes', async () => { + const h = await setupChatScreen({ engine: 'llama', platform: 'ios', deferInitialLoad: true }); + const { useRemoteServerStore } = require('../../../src/stores'); + const { setActiveRemoteTextModelImpl } = require('../../../src/services/remoteServerManagerUtils'); + + const remoteStore = useRemoteServerStore.getState(); + const serverId = remoteStore.addServer({ + name: 'Off Grid Desktop', + endpoint: 'http://192.168.5.219:7878', + providerType: 'openai-compatible', + }); + remoteStore.setDiscoveredModels(serverId, [{ + id: 'gemma-4-e4b', + name: 'Gemma 4 E4B', + capabilities: { + supportsVision: false, + supportsToolCalling: true, + supportsThinking: false, + acceptsThinkingKwarg: false, + }, + }]); + await setActiveRemoteTextModelImpl(serverId, 'gemma-4-e4b'); + + // Device-shaped race: provider selection is complete, but the background + // discovery refresh temporarily has no metadata for the chosen model. + useRemoteServerStore.getState().clearDiscoveredModels(serverId); + h.render(); + + await h.rtl.waitFor(() => { + expect(useRemoteServerStore.getState().activeServerId).toBe(serverId); + expect(useRemoteServerStore.getState().activeRemoteTextModelId).toBe('gemma-4-e4b'); + }); + expect(h.boundary.llama!.multimodalHoldActive()).toBe(false); + expect(h.view!.queryByText(/Loading Test Model/)).toBeNull(); }); }); diff --git a/__tests__/integration/happy/resend.happy.test.tsx b/__tests__/integration/happy/resend.happy.test.tsx index 2a018865e..857f04669 100644 --- a/__tests__/integration/happy/resend.happy.test.tsx +++ b/__tests__/integration/happy/resend.happy.test.tsx @@ -9,7 +9,12 @@ import { setupChatScreen } from '../../harness/chatHarness'; jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }), + useNavigation: () => ({ + navigate: () => {}, + goBack: () => {}, + setOptions: () => {}, + addListener: () => () => {}, + }), useRoute: () => require('../../harness/chatHarness').routeHolder, useFocusEffect: () => {}, useIsFocused: () => true, @@ -17,23 +22,41 @@ jest.mock('@react-navigation/native', () => ({ describe('happy — resend/regenerate (heavy entry point)', () => { // Retry is reached through the action menu, which opens BOTH via long-press AND the 3-dots '•••' button. - it.each(['longpress', 'dots'] as const)('llama.cpp: Retry (menu via %s) produces a fresh answer', async (via) => { - const h = await setupChatScreen({ engine: 'llama' }); - h.render(); - await h.send('tell me a fact', { text: 'Honey never spoils.' }); - await h.rtl.waitFor(() => { expect(h.view!.queryByText(/Honey never spoils\./)).not.toBeNull(); }); + it.each(['longpress', 'dots'] as const)( + 'llama.cpp: Retry (menu via %s) produces a fresh answer', + async via => { + const h = await setupChatScreen({ engine: 'llama' }); + h.render(); + await h.send('tell me a fact', { text: 'Honey never spoils.' }); + await h.rtl.waitFor(() => { + expect(h.view!.queryByText(/Honey never spoils\./)).not.toBeNull(); + }); - await h.regenerateLast({ text: 'Octopuses have three hearts.' }, via); - await h.rtl.waitFor(() => { expect(h.view!.queryByText(/Octopuses have three hearts\./)).not.toBeNull(); }); - }); + await h.regenerateLast({ text: 'Octopuses have three hearts.' }, via); + await h.rtl.waitFor(() => { + expect( + h.view!.queryByText(/Octopuses have three hearts\./), + ).not.toBeNull(); + }); + }, + ); - it.each(['longpress', 'dots'] as const)('LiteRT: Retry (menu via %s) produces a fresh answer', async (via) => { - const h = await setupChatScreen({ engine: 'litert' }); - h.render(); - await h.send('tell me a fact', { content: 'Honey never spoils.' }); - await h.rtl.waitFor(() => { expect(h.view!.queryByText(/Honey never spoils\./)).not.toBeNull(); }); + it.each(['longpress', 'dots'] as const)( + 'LiteRT: Retry (menu via %s) produces a fresh answer', + async via => { + const h = await setupChatScreen({ engine: 'litert' }); + h.render(); + await h.send('tell me a fact', { content: 'Honey never spoils.' }); + await h.rtl.waitFor(() => { + expect(h.view!.queryByText(/Honey never spoils\./)).not.toBeNull(); + }); - await h.regenerateLast({ content: 'Octopuses have three hearts.' }, via); - await h.rtl.waitFor(() => { expect(h.view!.queryByText(/Octopuses have three hearts\./)).not.toBeNull(); }); - }); + await h.regenerateLast({ content: 'Octopuses have three hearts.' }, via); + await h.rtl.waitFor(() => { + expect( + h.view!.queryByText(/Octopuses have three hearts\./), + ).not.toBeNull(); + }); + }, + ); }); diff --git a/__tests__/integration/happy/supportShareDismiss.happy.test.tsx b/__tests__/integration/happy/supportShareDismiss.happy.test.tsx index f512eca42..a076f0423 100644 --- a/__tests__/integration/happy/supportShareDismiss.happy.test.tsx +++ b/__tests__/integration/happy/supportShareDismiss.happy.test.tsx @@ -2,7 +2,7 @@ * HAPPY-PATH (UI integration, HEAVY entry point) — the "Support Open-Source AI" share sheet dismisses * after the user shares on X and does NOT re-nag on later generations. * - * Row T096 (Area 14): "Trigger the support-share sheet → tap Share on X → return to app → the sheet is + * Row T096 (Area 14): "Trigger the support sheet → tap Rate Us → return to app → the sheet is * dismissed (doesn't re-nag)". Device finding (docs/DEVICE_TEST_FINDINGS.md): "Support-sheet dismissal — * the 'support open source AI' share sheet dismisses correctly after returning from X (doesn't re-nag)." * @@ -10,12 +10,12 @@ * (checkSharePrompt increments the real textGenerationCount and, via shouldShowSharePrompt, emits the * prompt after 2 text generations, then again every 10th). The REAL SharePromptSheet renders inside * ChatScreen. We arrive at the sheet by SENDING real messages (never store.setState / emitSharePrompt), - * tap the REAL "Share on X" button, and assert on the RENDERED UI: + * tap the real store-rating button, and assert on the rendered UI: * 1. after the share, the sheet is gone, AND * 2. after driving generations up to the every-10th re-trigger count, the sheet does NOT re-appear. * * The ONLY device boundary faked is Linking.openURL (the leaf that hands the X compose intent to the OS — - * "tap Share on X"), plus the engine leaf via chatHarness. The re-nag guard under test is the REAL + * "tap Rate Us"), plus the engine leaf via chatHarness. The re-nag guard under test is the real * `hasEngagedSharePrompt` persistence: handleEngage sets it, and the real checkSharePrompt honors it. */ import { setupChatScreen } from '../../harness/chatHarness'; @@ -29,8 +29,12 @@ jest.mock('@react-navigation/native', () => ({ const SHEET_TITLE = 'Support Open-Source AI'; -describe('happy — support-share sheet dismisses after Share on X and does not re-nag (T096)', () => { - it('llama.cpp: 2nd generation shows the sheet; sharing to X dismisses it; the 10th does not re-nag', async () => { +// Either store's label is correct - which one renders depends on the platform the suite runs as, +// and the point of this test is the dismiss-and-do-not-re-nag behaviour, not the platform split. +const RATE_LABEL = /^Rate on (the App Store|Google Play)$/; + +describe('happy — support-share sheet dismisses after rating and does not re-nag (T096)', () => { + it('llama.cpp: 2nd generation shows the sheet; rating dismisses it; the 10th does not re-nag', async () => { const h = await setupChatScreen({ engine: 'llama', platform: 'android' }); h.render(); @@ -50,11 +54,11 @@ describe('happy — support-share sheet dismisses after Share on X and does not // shouldShowSharePrompt(2) is true, and (since not engaged) emits the prompt after the real delay. await h.send('second prompt', { text: 'reply two' }); await h.rtl.waitFor(() => { expect(h.view!.getByText(SHEET_TITLE)).toBeTruthy(); }, { timeout: 15000 }); - expect(h.view!.getByText('Share on X')).toBeTruthy(); + expect(h.view!.getByText(RATE_LABEL)).toBeTruthy(); - // GESTURE: tap "Share on X" — the REAL handleEngage sets hasEngagedSharePrompt, opens the X intent - // (the faked device leaf), and closes the sheet. - h.rtl.fireEvent.press(h.view!.getByText('Share on X')); + // GESTURE: tap the rate button — the REAL handleEngage sets hasEngagedSharePrompt, opens the + // store review page (the faked device leaf), and closes the sheet. + h.rtl.fireEvent.press(h.view!.getByText(RATE_LABEL)); // ASSERT (1): the sheet is dismissed after the share. Its title is gone from the rendered tree. // @@ -62,8 +66,14 @@ describe('happy — support-share sheet dismisses after Share on X and does not // took 9.6s. The assertion is unchanged - the wait just stops racing the hardware. It only surfaced // now because CI never reached jest before; the typecheck gate died first on an unbuilt package. await h.rtl.waitFor(() => { expect(h.view!.queryByText(SHEET_TITLE)).toBeNull(); }, { timeout: 15000 }); - // The X compose intent was actually handed to the OS (return-from-X boundary). - await h.rtl.waitFor(() => { expect(openURL).toHaveBeenCalledWith(expect.stringMatching(/^https:\/\/x\.com\/intent\/post/)); }); + // The store review page was actually handed to the OS (return-from-store boundary). Which store + // depends on the platform the test runs as, so accept either - the point is that the OS was asked + // to open a REVIEW destination, not that we hardcode one platform's URL into a shared test. + await h.rtl.waitFor(() => { + expect(openURL).toHaveBeenCalledWith( + expect.stringMatching(/^(https:\/\/apps\.apple\.com\/app\/id|market:\/\/details|https:\/\/play\.google\.com\/store)/), + ); + }); // "RETURN TO APP" + keep using it: drive generations up to the every-10th re-trigger count (10). If the // re-nag guard were broken, checkSharePrompt(10) would emit the prompt again and the sheet would reappear. diff --git a/__tests__/integration/happy/transcription.happy.test.ts b/__tests__/integration/happy/transcription.happy.test.ts index bf1a9bdee..43b9ebaa8 100644 --- a/__tests__/integration/happy/transcription.happy.test.ts +++ b/__tests__/integration/happy/transcription.happy.test.ts @@ -35,6 +35,7 @@ describe('happy — audio-mode transcription auto-sends the spoken text', () => const autoSendArgs: unknown[][] = []; const { result } = renderHook(() => useVoiceInput({ conversationId: 'c1', onTranscript: () => {}, + interfaceMode: 'audio', onAutoSend: (...a: unknown[]) => { autoSendArgs.push(a); }, onAudioAttachment: () => {}, })); diff --git a/__tests__/integration/home/homeRemoteModelTextCount.rendered.happy.test.tsx b/__tests__/integration/home/homeRemoteModelTextCount.rendered.happy.test.tsx index dea6a254a..98fd6d2df 100644 --- a/__tests__/integration/home/homeRemoteModelTextCount.rendered.happy.test.tsx +++ b/__tests__/integration/home/homeRemoteModelTextCount.rendered.happy.test.tsx @@ -43,7 +43,7 @@ describe('T097 (rendered) — Home Text count with a remote model active is not const React = require('react'); const rtl = requireRTL(); - const { RemoteServersScreen } = require('../../../src/screens/RemoteServersScreen'); + const { RemoteServerEditorScreen } = require('../../../src/screens/RemoteServerEditorScreen'); const { HomeScreen } = require('../../../src/screens/HomeScreen'); const { useRemoteServerStore, useAppStore } = require('../../../src/stores'); @@ -61,20 +61,19 @@ describe('T097 (rendered) — Home Text count with a remote model active is not }); const nav = { navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }; - return { React, rtl, RemoteServersScreen, HomeScreen, useRemoteServerStore, useAppStore, nav, opts }; + return { React, rtl, RemoteServerEditorScreen, HomeScreen, useRemoteServerStore, useAppStore, nav, opts }; }; // Arrive at "a remote server is connected + its models discovered" through the REAL Add-Server UI (T046 flow). const connectServerViaUI = async (env: ReturnType) => { - const { React, rtl, RemoteServersScreen, nav } = env; - const srv = rtl.render(React.createElement(RemoteServersScreen, { navigation: nav })); - rtl.fireEvent.press(srv.getByTestId('add-server')); - rtl.fireEvent.changeText(await rtl.waitFor(() => srv.getByPlaceholderText('e.g., Off Grid AI Desktop')), 'My LM Studio'); + const { React, rtl, RemoteServerEditorScreen } = env; + const srv = rtl.render(React.createElement(RemoteServerEditorScreen)); + rtl.fireEvent.changeText(await rtl.waitFor(() => srv.getByPlaceholderText('Off Grid AI Desktop')), 'My LM Studio'); rtl.fireEvent.changeText(srv.getByPlaceholderText('http://192.168.1.50:7878'), 'http://localhost:1234'); rtl.fireEvent.press(srv.getByTestId('test-connection')); await rtl.waitFor(() => { expect(srv.queryByText(/Connected \(/)).not.toBeNull(); }, { timeout: 4000 }); rtl.fireEvent.press(srv.getByTestId('save-server')); - await rtl.waitFor(() => { expect(srv.queryByText('My LM Studio')).not.toBeNull(); }, { timeout: 4000 }); + await rtl.waitFor(() => { expect(env.useRemoteServerStore.getState().servers).toHaveLength(1); }, { timeout: 4000 }); srv.unmount(); }; diff --git a/__tests__/integration/home/modelsSheetRemoteCloud.rendered.test.tsx b/__tests__/integration/home/modelsSheetRemoteCloud.rendered.test.tsx index dfa22520d..b0dc59142 100644 --- a/__tests__/integration/home/modelsSheetRemoteCloud.rendered.test.tsx +++ b/__tests__/integration/home/modelsSheetRemoteCloud.rendered.test.tsx @@ -21,7 +21,7 @@ describe('Models manager sheet — remote TEXT selection carries the cloud marke const React = require('react'); const rtl = requireRTL(); - const { RemoteServersScreen } = require('../../../src/screens/RemoteServersScreen'); + const { RemoteServerEditorScreen } = require('../../../src/screens/RemoteServerEditorScreen'); const { HomeScreen } = require('../../../src/screens/HomeScreen'); const { useRemoteServerStore, useAppStore } = require('../../../src/stores'); @@ -37,20 +37,19 @@ describe('Models manager sheet — remote TEXT selection carries the cloud marke }); const nav = { navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }; - return { React, rtl, RemoteServersScreen, HomeScreen, useRemoteServerStore, nav }; + return { React, rtl, RemoteServerEditorScreen, HomeScreen, useRemoteServerStore, nav }; }; /** Connect a server through the REAL Add-Server UI (the T046/T097 gesture chain). */ const connectServerViaUI = async (env: ReturnType) => { - const { React, rtl, RemoteServersScreen, nav } = env; - const srv = rtl.render(React.createElement(RemoteServersScreen, { navigation: nav })); - rtl.fireEvent.press(srv.getByTestId('add-server')); - rtl.fireEvent.changeText(await rtl.waitFor(() => srv.getByPlaceholderText('e.g., Off Grid AI Desktop')), 'My LM Studio'); + const { React, rtl, RemoteServerEditorScreen } = env; + const srv = rtl.render(React.createElement(RemoteServerEditorScreen)); + rtl.fireEvent.changeText(await rtl.waitFor(() => srv.getByPlaceholderText('Off Grid AI Desktop')), 'My LM Studio'); rtl.fireEvent.changeText(srv.getByPlaceholderText('http://192.168.1.50:7878'), 'http://localhost:1234'); rtl.fireEvent.press(srv.getByTestId('test-connection')); await rtl.waitFor(() => { expect(srv.queryByText(/Connected \(/)).not.toBeNull(); }, { timeout: 4000 }); rtl.fireEvent.press(srv.getByTestId('save-server')); - await rtl.waitFor(() => { expect(srv.queryByText('My LM Studio')).not.toBeNull(); }, { timeout: 4000 }); + await rtl.waitFor(() => { expect(env.useRemoteServerStore.getState().servers).toHaveLength(1); }, { timeout: 4000 }); srv.unmount(); }; diff --git a/__tests__/integration/licensing/keygenAutomaticReplacement.test.ts b/__tests__/integration/licensing/keygenAutomaticReplacement.test.ts index ed6321b60..d59b991de 100644 --- a/__tests__/integration/licensing/keygenAutomaticReplacement.test.ts +++ b/__tests__/integration/licensing/keygenAutomaticReplacement.test.ts @@ -93,10 +93,8 @@ describe('activating a key when every seat is taken', () => { service.registerProEntitlementProvider, ); - // The mesh half, registered the way syncService registers it. Without an owner the provider WAITS for - // one and then reports the network as unavailable - which is right in production (a device with no mesh - // running cannot claim a seat) and is why the two success cases here were timing out rather than failing. - // The adapter is the real one; only its callbacks into the stores are dropped, since no UI is mounted. + // Register the same entitlement host that syncService prepares before the slower transport startup. + // The adapter is real. Only its UI callbacks are dropped because no screen is mounted. const { createPairingEntitlementHostAdapter, } = require('../../../pro/sync/pairingEntitlementCredentialAdapter'); @@ -127,21 +125,7 @@ describe('activating a key when every seat is taken', () => { const heldFingerprints = (): string[] => keygen.machines(LICENCE_KEY).map(({ fingerprint }) => fingerprint); - /** - * SKIPPED, and not because the assertion is wrong: both need a mesh RUNTIME. - * - * PersonalMeshRegistrationCoordinator.prepare() goes through the membership adapter for every - * registration, not only for a replacement, and that adapter needs a live runtime to evict a membership - * through (syncService passes `membershipOwner: () => runtimeRef`). With no runtime the activation refuses - * with 'replacement_failed' even when a seat is free - which is honest behaviour for a device whose mesh is - * not running, and is exactly what the two cases below cannot express yet. - * - * Standing up that runtime in the harness is its own piece of work (backlog: "Make pairing work in the - * mobile test harness"). The DECISION these two describe - retire the least recently active installation - - * is covered where it now lives, in shared's personal-mesh-entitlement suite, which drives the coordinator - * directly with an 'away-longest' installation. Un-skip once the harness can start a runtime. - */ - it.skip('takes the free seat when the licence has one', async () => { + it('takes a free seat before the full sync runtime starts', async () => { keygen.addLicence({ key: LICENCE_KEY, seats: CAP }); keygen.activate({ key: LICENCE_KEY, fingerprint: 'fp-away-longest', platform: 'ios' }); @@ -152,6 +136,8 @@ describe('activating a key when every seat is taken', () => { expect(heldFingerprints()).toContain('fp-sixth-device'); }); + /** A full mesh still needs its live runtime to retire the replaced device membership. */ + // eslint-disable-next-line jest/no-disabled-tests -- the free-seat path above is the release regression it.skip('retires the device that has been away longest, and admits this one', async () => { const fingerprints = fillEverySeat(); expect(keygen.machines(LICENCE_KEY)).toHaveLength(CAP); diff --git a/__tests__/integration/licensing/proRuntimeExpiry.test.ts b/__tests__/integration/licensing/proRuntimeExpiry.test.ts new file mode 100644 index 000000000..b3456fd0b --- /dev/null +++ b/__tests__/integration/licensing/proRuntimeExpiry.test.ts @@ -0,0 +1,80 @@ +import { callHook, _clearHooksForTesting } from '../../../src/bootstrap/hookRegistry'; +import { getSlot, SLOTS, _clearSlotsForTesting } from '../../../src/bootstrap/slotRegistry'; +import { + getRegisteredScreens, + registerScreen, + _clearScreensForTesting, +} from '../../../src/navigation/screenRegistry'; +import { + getToolExtensions, + registerToolExtension, + _clearExtensionsForTesting, +} from '../../../src/services/tools/extensions'; +import { registerSettingsSection } from '../../../src/components/settings/sectionRegistry'; +import { registerHook } from '../../../src/bootstrap/hookRegistry'; +import { registerSlot } from '../../../src/bootstrap/slotRegistry'; +import { activate, deactivate } from '../../../pro'; +import { syncService } from '../../../pro/sync/syncService'; + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +describe('the Pro runtime when access expires', () => { + beforeEach(() => { + _clearHooksForTesting(); + _clearSlotsForTesting(); + _clearScreensForTesting(); + _clearExtensionsForTesting(); + }); + + afterEach(async () => { + await deactivate(); + // The restricted Sync bootstrap intentionally survives Pro deactivation. This test starts that + // process, so it must also await and stop it before Jest removes the native boundary. + await syncService.stop(); + _clearHooksForTesting(); + _clearSlotsForTesting(); + _clearScreensForTesting(); + _clearExtensionsForTesting(); + }); + + it('removes every paid surface and stops Sync without an app restart', async () => { + activate({ + registerToolExtension, + registerScreen, + registerSettingsSection, + registerSlot, + registerHook, + }); + + expect(getRegisteredScreens().map(screen => screen.name)).toEqual( + expect.arrayContaining(['Sync', 'Clipboard', 'McpServers']), + ); + expect(getToolExtensions().map(extension => extension.id)).toEqual( + expect.arrayContaining(['mcp', 'email-calendar']), + ); + expect(getSlot(SLOTS.appRoot)).toBeDefined(); + expect(callHook('audio.canSpeak')).toBeDefined(); + + await deactivate(); + + expect(getRegisteredScreens().map(screen => screen.name)).not.toEqual( + expect.arrayContaining(['Sync', 'Clipboard', 'McpServers']), + ); + expect(getToolExtensions().map(extension => extension.id)).toEqual([]); + expect(getSlot(SLOTS.appRoot)).toBeUndefined(); + expect(callHook('audio.canSpeak')).toBeUndefined(); + expect(syncService.isRunning()).toBe(false); + }); +}); diff --git a/__tests__/integration/memory/curatedLiteRTOverBudgetWarning.rendered.redflow.test.tsx b/__tests__/integration/memory/curatedLiteRTOverBudgetWarning.rendered.redflow.test.tsx index 5a732acd5..78ada20c4 100644 --- a/__tests__/integration/memory/curatedLiteRTOverBudgetWarning.rendered.redflow.test.tsx +++ b/__tests__/integration/memory/curatedLiteRTOverBudgetWarning.rendered.redflow.test.tsx @@ -23,7 +23,7 @@ * RED on HEAD: the pre-filter drops BOTH (both exceed 2.0GB), so the E4B card is ABSENT → warning * unreachable. GREEN after fix: E4B present + its download tap surfaces the warning; E2B stays hidden. * - * Real ModelDownloadScreen + real hardwareService/memoryBudget/curated registry + real CustomAlert; + * Real AdvancedSetupScreen + real hardwareService/memoryBudget/curated registry + real CustomAlert; * fakes ONLY at the native RAM-sensor boundary (installNativeBoundary). NEVER mocks our own code. */ import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary'; @@ -45,17 +45,17 @@ describe('Curated LiteRT onboarding — an over-budget model that HAS a warning const React = require('react'); const rtl = requireRTL(); const { hardwareService } = require('../../../src/services/hardware'); - const { ModelDownloadScreen } = require('../../../src/screens/ModelDownloadScreen'); + const { AdvancedSetupScreen } = require('../../../src/screens/ModelDownloadScreen'); // Prime the RAM cache the same way the screen's own effect does (getDeviceInfo → getTotalMemoryGB // reads cachedDeviceInfo). This is a device-boundary read, not our state. await hardwareService.getDeviceInfo(); const nav: any = { navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {}, replace: () => {} }; - const view = rtl.render(React.createElement(ModelDownloadScreen, { navigation: nav })); + const view = rtl.render(React.createElement(AdvancedSetupScreen, { navigation: nav })); // Wait for the async init effect to settle (loading → loaded). - await rtl.waitFor(() => { expect(view.getByText('Set Up Your AI')).toBeTruthy(); }, { timeout: 10000 }); + await rtl.waitFor(() => { expect(view.getByText('Advanced Setup')).toBeTruthy(); }, { timeout: 10000 }); // The over-budget-but-warnable E4B card IS offered. RED on HEAD: pre-filter dropped it → the // curated LiteRT list was empty (both files over budget). Assert on the LiteRT card specifically diff --git a/__tests__/integration/memory/lazyReloadAfterEject.rendered.redflow.test.tsx b/__tests__/integration/memory/lazyReloadAfterEject.rendered.redflow.test.tsx index 5342b5d2c..16f2980dd 100644 --- a/__tests__/integration/memory/lazyReloadAfterEject.rendered.redflow.test.tsx +++ b/__tests__/integration/memory/lazyReloadAfterEject.rendered.redflow.test.tsx @@ -10,18 +10,29 @@ import { setupChatScreen } from '../../harness/chatHarness'; jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }), + useNavigation: () => ({ + navigate: () => {}, + goBack: () => {}, + setOptions: () => {}, + addListener: () => () => {}, + }), useRoute: () => require('../../harness/chatHarness').routeHolder, - useFocusEffect: () => {}, useIsFocused: () => true, + useFocusEffect: () => {}, + useIsFocused: () => true, })); describe('per-model eject — lazy reload on next use', () => { it('reloads an ejected text model when a message is sent, and answers', async () => { const h = await setupChatScreen({ engine: 'litert', platform: 'android' }); h.render(); - - const { modelResidencyManager } = require('../../../src/services/modelResidency'); - const textResident = () => (modelResidencyManager.getResidents() as Array<{ type: string }>).some(r => r.type === 'text'); + + const { + modelResidencyManager, + } = require('../../../src/services/modelResidency'); + const textResident = () => + (modelResidencyManager.getResidents() as Array<{ type: string }>).some( + r => r.type === 'text', + ); // Text model is resident after load. expect(textResident()).toBe(true); @@ -32,7 +43,12 @@ describe('per-model eject — lazy reload on next use', () => { // When needed again, sending a message lazy-reloads it and the answer renders. await h.send('what is 2 plus 2', { content: 'It is 4.' }); - await h.rtl.waitFor(() => { expect(h.view!.queryByText(/It is 4\./)).not.toBeNull(); }, { timeout: 6000 }); + await h.rtl.waitFor( + () => { + expect(h.view!.queryByText(/It is 4\./)).not.toBeNull(); + }, + { timeout: 6000 }, + ); // ...and it is resident again. expect(textResident()).toBe(true); diff --git a/__tests__/integration/models/addServerSheet.rendered.happy.test.tsx b/__tests__/integration/models/addServerSheet.rendered.happy.test.tsx index 72a174ff2..eab3e74c5 100644 --- a/__tests__/integration/models/addServerSheet.rendered.happy.test.tsx +++ b/__tests__/integration/models/addServerSheet.rendered.happy.test.tsx @@ -1,43 +1,37 @@ /** - * The add / edit server sheet, driven the way a user drives it. - * - * Replaces `__tests__/rntl/components/RemoteServerModal.test.tsx`, which mocked six of our own - * modules - the sheet container, the manager, the STORE, the http client, the theme and the alert. - * With the store mocked it could not observe the one thing that matters, which is whether a server - * ends up in your list, so it asserted that a mock had been called instead. - * - * What is covered HERE is only what nothing else covers. The happy path (open, type, test, save, - * see it Connected) already has a home in `remoteServerConnect.rendered.happy.test.tsx`, so this - * takes the three behaviours that had no honest test: - * 1. a refusal - a malformed address is rejected and adds nothing, - * 2. the privacy warning - it appears for an address off your network and not for one on it, - * 3. editing - a rename reaches the list. - * - * Everything runs for real, from the screen down through the form, the manager and the store. - * Fakes sit at the device boundary only: this phone's address, and the network itself. + * Full-screen remote-server editor through the real form, manager, store, Keychain adapter, and + * HTTP client. Only the network and navigation hosts are test boundaries. */ import React from 'react'; -import { render, fireEvent, waitFor } from '@testing-library/react-native'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import { RemoteServerEditorScreen } from '../../../src/screens/RemoteServerEditorScreen'; +import { RemoteServersScreen } from '../../../src/screens/RemoteServersScreen'; +import { useRemoteServerStore } from '../../../src/stores'; +import { + gatewayModelList, + installLanProbe, + type LanProbeHandle, +} from '../../harness/lanProbe'; + +const mockRoute: { params?: { serverId?: string } } = {}; +const mockGoBack = jest.fn(); jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }), + useNavigation: () => ({ navigate: jest.fn(), goBack: mockGoBack }), + useRoute: () => mockRoute, useIsFocused: () => true, useFocusEffect: () => {}, })); -import { RemoteServersScreen } from '../../../src/screens/RemoteServersScreen'; -import { useRemoteServerStore } from '../../../src/stores'; -import { installLanProbe, gatewayModelList, type LanProbeHandle } from '../../harness/lanProbe'; - const MAC = '192.168.1.30:7878'; -const PUBLIC_ENDPOINT = 'https://api.groq.com'; -describe('adding a server by hand', () => { +describe('full-screen remote server editor', () => { let lan: LanProbeHandle; beforeEach(() => { - useRemoteServerStore.setState({ servers: [], serverHealth: {}, discoveredModels: {} }); - + mockRoute.params = undefined; + mockGoBack.mockClear(); + useRemoteServerStore.getState().clearAllServers(); const DeviceInfo = require('react-native-device-info'); DeviceInfo.isEmulator = jest.fn(async () => false); DeviceInfo.getIpAddress = jest.fn(async () => '192.168.1.10'); @@ -46,68 +40,53 @@ describe('adding a server by hand', () => { afterEach(() => lan.uninstall()); - /** Open the sheet the way a user does: from the screen's own button. */ - const openSheet = () => { - const ui = render(); - fireEvent.press(ui.getByTestId('add-server')); - return ui; - }; - - it('refuses an address that is not a URL, and adds nothing', async () => { - const ui = openSheet(); - - fireEvent.changeText(ui.getByPlaceholderText('e.g., Off Grid AI Desktop'), 'My Mac'); - fireEvent.changeText(ui.getByPlaceholderText('http://192.168.1.50:7878'), 'not-a-url'); - - // BEFORE: no complaint on screen yet. - expect(ui.queryByText('Invalid URL format')).toBeNull(); - + it('rejects an invalid address before it creates a server', async () => { + const ui = render(); + fireEvent.changeText(ui.getByTestId('server-name'), 'Study Mac'); + fireEvent.changeText(ui.getByTestId('server-endpoint'), 'not-a-url'); fireEvent.press(ui.getByTestId('test-connection')); - await waitFor(() => { expect(ui.queryByText('Invalid URL format')).not.toBeNull(); }); - - // The list is untouched: a rejected address must not leave a half-made server behind. + await waitFor(() => expect(ui.getByText('Invalid URL format')).toBeTruthy()); expect(useRemoteServerStore.getState().servers).toHaveLength(0); }); - it('warns when the address is off your own network, and stops warning when it is on it', async () => { - const ui = openSheet(); - const address = ui.getByPlaceholderText('http://192.168.1.50:7878'); + it('states when media leaves the phone and when it stays on the local network', async () => { + const ui = render(); + const address = ui.getByTestId('server-endpoint'); - // An address on your own network says nothing: the data never leaves the house. fireEvent.changeText(address, `http://${MAC}`); - expect(ui.queryByText(/leaves your network/)).toBeNull(); - - // An address on the public internet warns, and says what actually happens to the data. - fireEvent.changeText(address, PUBLIC_ENDPOINT); - await waitFor(() => { expect(ui.queryByText(/leaves your network/)).not.toBeNull(); }); - - // Back to a private address and the warning goes: it tracks the address, it does not latch. - fireEvent.changeText(address, `http://${MAC}`); - await waitFor(() => { expect(ui.queryByText(/leaves your network/)).toBeNull(); }); + expect( + ui.getByText('A server on your network keeps requests between your devices.'), + ).toBeTruthy(); + + fireEvent.changeText(address, 'https://api.example.com'); + await waitFor(() => + expect(ui.getByText(/prompts, images, and audio leave this phone/)).toBeTruthy(), + ); + expect(ui.getByText('The key stays in Keychain on this phone.')).toBeTruthy(); }); - it('renames a server through Edit, and the list shows the new name', async () => { - const ui = openSheet(); - - // Arrive at "a server exists" the way a user does - add one - rather than writing the store. - fireEvent.changeText(ui.getByPlaceholderText('e.g., Off Grid AI Desktop'), 'Old name'); - fireEvent.changeText(ui.getByPlaceholderText('http://192.168.1.50:7878'), `http://${MAC}`); - fireEvent.press(ui.getByTestId('test-connection')); - await waitFor(() => { expect(ui.getByTestId('save-server')).toBeTruthy(); }); - fireEvent.press(ui.getByTestId('save-server')); - await waitFor(() => { expect(ui.queryByText('Old name')).not.toBeNull(); }); - - // Now the real gesture under test: Edit the row, change the name, save. - fireEvent.press(ui.getByText('Edit')); - fireEvent.changeText(ui.getByPlaceholderText('e.g., Off Grid AI Desktop'), 'Mac in the study'); - fireEvent.press(ui.getByTestId('test-connection')); - await waitFor(() => { expect(ui.getByTestId('save-server')).toBeTruthy(); }); - fireEvent.press(ui.getByTestId('save-server')); - - // The list shows the new name, and the old one is gone - a rename, not a second row. - await waitFor(() => { expect(ui.queryByText('Mac in the study')).not.toBeNull(); }); - expect(ui.queryByText('Old name')).toBeNull(); - expect(useRemoteServerStore.getState().servers).toHaveLength(1); + it('saves media model IDs and shows the named server in the list', async () => { + const editor = render(); + fireEvent.changeText(editor.getByTestId('server-name'), 'Study Mac'); + fireEvent.changeText(editor.getByTestId('server-endpoint'), `http://${MAC}`); + fireEvent.changeText(editor.getByPlaceholderText('gpt-image-1'), 'flux-schnell'); + fireEvent.changeText(editor.getByPlaceholderText('whisper-1'), 'whisper-large-v3'); + fireEvent.changeText(editor.getByPlaceholderText('gpt-4o-mini-tts'), 'kokoro'); + fireEvent.press(editor.getByTestId('test-connection')); + await waitFor(() => expect(editor.getByText(/Connected/)).toBeTruthy()); + fireEvent.press(editor.getByTestId('save-server')); + + await waitFor(() => expect(useRemoteServerStore.getState().servers).toHaveLength(1)); + expect(useRemoteServerStore.getState().servers[0]?.mediaModels).toEqual({ + image: 'flux-schnell', + transcription: 'whisper-large-v3', + voice: 'kokoro', + }); + expect(useRemoteServerStore.getState().servers[0]).not.toHaveProperty('apiKey'); + + editor.unmount(); + const list = render(); + await waitFor(() => expect(list.getByText('Study Mac')).toBeTruthy()); }); }); diff --git a/__tests__/integration/models/imageQueuedCardState.test.tsx b/__tests__/integration/models/imageQueuedCardState.test.tsx index b6ff55bd5..947425d07 100644 --- a/__tests__/integration/models/imageQueuedCardState.test.tsx +++ b/__tests__/integration/models/imageQueuedCardState.test.tsx @@ -66,7 +66,7 @@ jest.mock('../../../src/services', () => ({ // REAL store, REAL key helper, REAL card, REAL download action. import { useDownloadStore, isActiveStatus, isQueuedStatus } from '../../../src/stores/downloadStore'; import { makeImageModelKey } from '../../../src/utils/modelKey'; -import { proceedWithDownload } from '../../../src/screens/ModelsScreen/imageDownloadActions'; +import { proceedWithDownload } from '../../../src/services/imageDownloadActions'; import { ImageModelCardItem } from '../../../src/screens/ModelsScreen/ImageModelsTab'; import { makeImageDownloadDeps } from '../../utils/factories'; diff --git a/__tests__/integration/models/sttResidency.test.ts b/__tests__/integration/models/sttResidency.test.ts index 9b78ace50..152327c6e 100644 --- a/__tests__/integration/models/sttResidency.test.ts +++ b/__tests__/integration/models/sttResidency.test.ts @@ -24,11 +24,19 @@ import { hardwareService } from '../../../src/services/hardware'; // Native boundary: the whisper native model. A dumb stub that just flips a flag // so the REAL residency bookkeeping and the REAL store logic run on top of it. let mockWhisperNativeLoaded = false; +let mockWhisperModelPath: string | null = null; jest.mock('../../../src/services/whisperService', () => ({ whisperService: { getModelPath: (id: string) => `/models/ggml-${id}.bin`, - loadModel: jest.fn(async () => { mockWhisperNativeLoaded = true; }), - unloadModel: jest.fn(async () => { mockWhisperNativeLoaded = false; }), + getLoadedModelPath: () => mockWhisperModelPath, + loadModel: jest.fn(async (path: string) => { + mockWhisperNativeLoaded = true; + mockWhisperModelPath = path; + }), + unloadModel: jest.fn(async () => { + mockWhisperNativeLoaded = false; + mockWhisperModelPath = null; + }), isModelLoaded: () => mockWhisperNativeLoaded, isModelDownloaded: jest.fn(async () => true), deleteModel: jest.fn(async () => {}), @@ -57,6 +65,7 @@ describe('STT residency — single-model invariant', () => { beforeEach(() => { jest.clearAllMocks(); mockWhisperNativeLoaded = false; + mockWhisperModelPath = null; modelResidencyManager._reset(); useWhisperStore.setState({ downloadedModelId: 'base', isModelLoaded: false, isModelLoading: false, error: null }); diff --git a/__tests__/integration/onboarding/autoSetupJourney.test.tsx b/__tests__/integration/onboarding/autoSetupJourney.test.tsx new file mode 100644 index 000000000..b2049528a --- /dev/null +++ b/__tests__/integration/onboarding/autoSetupJourney.test.tsx @@ -0,0 +1,346 @@ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import { AutoSetupScreen } from '../../../src/screens/AutoSetupScreen'; +import { type AutoSetupCatalogBoundaries } from '../../../src/services/autoSetupCatalog'; +import { + createAutoSetupSession, + type AutoSetupDownloadBoundaries, +} from '../../../src/services/autoSetupService'; +import { modelDownloadService } from '../../../src/services/modelDownloadService'; +import type { + DownloadProvider, + ModelDownload, + ModelDownloadType, +} from '../../../src/services/modelDownloadService/types'; +import { uniformDownloadId } from '../../../src/services/modelDownloadService/uniformId'; +import { useAppStore } from '../../../src/stores'; + +const MB = 1024 * 1024; +const parameterCount = (modelId: string): number => { + if (modelId.includes('9B')) return 9; + if (modelId.includes('E4B')) return 4; + if (modelId.includes('2.2B')) return 2.2; + if (modelId.includes('2B') || modelId.includes('E2B')) return 2; + if (modelId.includes('0.8B')) return 0.8; + return 1; +}; +const capabilities = { + cancel: true, + retry: true, + remove: true, + resumable: true, + determinateProgress: true, +}; + +class NativeDownloadBoundary implements DownloadProvider { + private downloads: ModelDownload[] = []; + private readonly listeners = new Set<() => void>(); + completeOnStart = true; + + constructor(readonly modelType: ModelDownloadType) {} + + async list(): Promise { + return this.downloads; + } + + start(id: string, name: string, sizeBytes: number): Promise { + this.downloads = [ + { + id: uniformDownloadId(this.modelType, id), + modelType: this.modelType, + name, + sizeBytes, + bytesDownloaded: this.completeOnStart ? sizeBytes : 0, + progress: this.completeOnStart ? 1 : 0, + status: this.completeOnStart ? 'completed' : 'downloading', + capabilities, + }, + ]; + this.listeners.forEach(listener => listener()); + return Promise.resolve(); + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async cancel(): Promise { + this.downloads = []; + this.listeners.forEach(listener => listener()); + } + async retry(): Promise {} + async remove(): Promise { + this.downloads = []; + this.listeners.forEach(listener => listener()); + } +} + +const catalogBoundaries: AutoSetupCatalogBoundaries = { + totalMemoryGB: () => 12, + fetchTextFiles: async models => + Object.fromEntries( + models.map(model => { + const params = parameterCount(model.id); + return [ + model.id, + [ + { + name: `${params}b.gguf`, + size: params * 100 * MB, + quantization: 'Q4_K_M', + downloadUrl: `https://models.test/${params}b.gguf`, + }, + ], + ]; + }), + ), + imageRecommendation: async () => ({ + compatibleBackends: ['coreml'], + recommendedModels: ['balanced image'], + recommendedBackend: 'coreml', + bannerText: 'Core ML is ready.', + }), + imageModels: async () => [ + { + id: 'image-lean', + name: 'Lean image', + description: 'Lean image', + size: 100 * MB, + downloadUrl: 'https://models.test/image-lean', + style: 'general', + backend: 'coreml', + }, + { + id: 'image-balanced', + name: 'Balanced image', + description: 'Balanced image', + size: 200 * MB, + downloadUrl: 'https://models.test/image-balanced', + style: 'general', + backend: 'coreml', + }, + { + id: 'image-extreme', + name: 'Extreme image', + description: 'Extreme image', + size: 300 * MB, + downloadUrl: 'https://models.test/image-extreme', + style: 'general', + backend: 'coreml', + }, + ], +}; + +describe('Auto Setup release journey', () => { + const navigation = { navigate: jest.fn(), replace: jest.fn() } as any; + const textDownloads = new NativeDownloadBoundary('text'); + const imageDownloads = new NativeDownloadBoundary('image'); + const speechDownloads = new NativeDownloadBoundary('stt'); + let unregister: Array<() => void> = []; + + const downloadBoundaries: AutoSetupDownloadBoundaries = { + startText: async (modelId, file) => + textDownloads.start(`${modelId}/${file.name}`, file.name, file.size), + startImage: async model => + imageDownloads.start(model.id, model.name, model.size), + startSpeech: async modelId => + speechDownloads.start(modelId, modelId, 1 * MB), + list: () => modelDownloadService.list(), + cancel: id => modelDownloadService.cancel(id), + subscribe: listener => modelDownloadService.subscribe(listener), + }; + + const sessionFactory = () => + createAutoSetupSession({ + catalog: catalogBoundaries, + downloads: downloadBoundaries, + }); + + beforeEach(() => { + jest.clearAllMocks(); + useAppStore.getState().updateSettings({ modelLoadingMode: 'balanced' }); + useAppStore.setState({ activeModelId: null }); + textDownloads.completeOnStart = true; + imageDownloads.completeOnStart = true; + speechDownloads.completeOnStart = true; + unregister = [ + modelDownloadService.register(textDownloads), + modelDownloadService.register(imageDownloads), + modelDownloadService.register(speechDownloads), + ]; + }); + + afterEach(async () => { + await Promise.all([ + textDownloads.remove(), + imageDownloads.remove(), + speechDownloads.remove(), + ]); + unregister.forEach(dispose => dispose()); + }); + + it('selects a device-fit plan, starts all model downloads, and activates it', async () => { + const ui = render( + , + ); + await waitFor(() => + expect(ui.getByTestId('auto-setup-plan-balanced')).toBeTruthy(), + ); + + expect(ui.getAllByText('INCLUDES')).toHaveLength(1); + expect(ui.getByText('Gemma 4 E4B')).toBeTruthy(); + fireEvent.press(ui.getByTestId('auto-setup-plan-extreme')); + expect(ui.getByText('Qwen 3.5 9B')).toBeTruthy(); + expect(ui.queryByText('Gemma 4 E4B')).toBeNull(); + expect(useAppStore.getState().settings.modelLoadingMode).toBe('aggressive'); + + fireEvent.press(ui.getByTestId('auto-setup-download')); + await waitFor(() => + expect(ui.getByTestId('auto-setup-continue')).toBeTruthy(), + ); + expect( + (await modelDownloadService.list()).map(item => item.modelType), + ).toEqual(expect.arrayContaining(['text', 'image', 'stt'])); + + fireEvent.press(ui.getByTestId('auto-setup-continue')); + expect(useAppStore.getState().activeModelId).toContain( + 'unsloth/Qwen3.5-9B-GGUF', + ); + expect(navigation.replace).toHaveBeenCalledWith('Main'); + }); + + it('keeps manual model and remote server setup in Advanced Setup', async () => { + const ui = render( + , + ); + await waitFor(() => + expect(ui.getByTestId('auto-setup-advanced')).toBeTruthy(), + ); + fireEvent.press(ui.getByTestId('auto-setup-advanced')); + expect(navigation.navigate).toHaveBeenCalledWith('AdvancedSetup'); + }); + + it('shows the failed model and cancels the other downloads as one session', async () => { + textDownloads.completeOnStart = false; + speechDownloads.completeOnStart = false; + const failingDownloads: AutoSetupDownloadBoundaries = { + ...downloadBoundaries, + startImage: async () => { + return Promise.reject({ + message: 'Image model download could not start.', + }); + }, + }; + const ui = render( + + createAutoSetupSession({ + catalog: catalogBoundaries, + downloads: failingDownloads, + }) + } + />, + ); + await waitFor(() => + expect(ui.getByTestId('auto-setup-download')).toBeTruthy(), + ); + + fireEvent.press(ui.getByTestId('auto-setup-download')); + + await waitFor(() => + expect( + ui.getByText('Image model download could not start.'), + ).toBeTruthy(), + ); + expect(ui.getByText(/FAILED/)).toBeTruthy(); + await waitFor(async () => { + const active = (await modelDownloadService.list()).filter( + download => download.status === 'downloading', + ); + expect(active).toEqual([]); + }); + }); + + it('cancels active downloads when setup unmounts', async () => { + textDownloads.completeOnStart = false; + imageDownloads.completeOnStart = false; + speechDownloads.completeOnStart = false; + const ui = render( + , + ); + await waitFor(() => + expect(ui.getByTestId('auto-setup-download')).toBeTruthy(), + ); + fireEvent.press(ui.getByTestId('auto-setup-download')); + await waitFor(() => + expect(ui.getAllByText(/STARTING|0%/).length).toBeGreaterThan(0), + ); + + ui.unmount(); + + await waitFor(async () => { + expect(await modelDownloadService.list()).toEqual([]); + }); + }); + + it('keeps the selected plan fixed after its download session starts', async () => { + textDownloads.completeOnStart = false; + imageDownloads.completeOnStart = false; + speechDownloads.completeOnStart = false; + const ui = render( + , + ); + await waitFor(() => + expect(ui.getByTestId('auto-setup-plan-balanced')).toBeTruthy(), + ); + + fireEvent.press(ui.getByTestId('auto-setup-download')); + await waitFor(() => + expect(ui.getAllByText(/STARTING|0%/).length).toBeGreaterThan(0), + ); + fireEvent.press(ui.getByTestId('auto-setup-plan-extreme')); + + expect(ui.getByText('Gemma 4 E4B')).toBeTruthy(); + expect(ui.queryByText('Qwen 3.5 9B')).toBeNull(); + expect(useAppStore.getState().settings.modelLoadingMode).toBe('balanced'); + ui.unmount(); + }); + + it('ends a stalled catalog request at its deadline', async () => { + const stalledCatalog: AutoSetupCatalogBoundaries = { + ...catalogBoundaries, + fetchTextFiles: () => new Promise(() => undefined), + }; + const ui = render( + + createAutoSetupSession({ + catalog: stalledCatalog, + downloads: downloadBoundaries, + catalogDeadlineMs: 5, + }) + } + />, + ); + + expect( + await ui.findByText('The model catalog did not respond in time.'), + ).toBeTruthy(); + ui.unmount(); + }); +}); diff --git a/__tests__/integration/onboarding/proBootFlow.test.ts b/__tests__/integration/onboarding/proBootFlow.test.ts index c072c7f63..dd8e412c4 100644 --- a/__tests__/integration/onboarding/proBootFlow.test.ts +++ b/__tests__/integration/onboarding/proBootFlow.test.ts @@ -134,6 +134,30 @@ describe('opening the app as a Pro user, and as a free one', () => { expect(storeState().isProActive).toBe(false); }); + it('does not boot Pro from an expired saved credential', async () => { + vault.set( + 'off-grid-pro-license', + JSON.stringify({ + isPro: true, + key: LICENCE_KEY, + licenseId: licenceId, + expiry: new Date(Date.now() - 1).toISOString(), + verifiedAt: Date.now() - 10_000, + }), + ); + + const active = await launch(); + + expect(active).toBe(false); + expect(activate).not.toHaveBeenCalled(); + expect(storeState()).toMatchObject({ + isProActive: false, + hasRegisteredPro: false, + hasSavedProCredential: true, + hasExpiredProCredential: true, + }); + }); + it('switches the licensed half on for a phone that already holds one', async () => { // A Pro phone holds a seat on the licence as well as a key in its keychain. Without the seat the // launch-time check correctly withdraws Pro - a key that no longer has a device registered against diff --git a/__tests__/integration/onboarding/scanNetworkAlertMatchesList.test.tsx b/__tests__/integration/onboarding/scanNetworkAlertMatchesList.test.tsx index 74ce5c320..24ff92240 100644 --- a/__tests__/integration/onboarding/scanNetworkAlertMatchesList.test.tsx +++ b/__tests__/integration/onboarding/scanNetworkAlertMatchesList.test.tsx @@ -53,7 +53,7 @@ jest.mock('react-native-safe-area-context', () => { }; }); -import { ModelDownloadScreen } from '../../../src/screens/ModelDownloadScreen'; +import { AdvancedSetupScreen } from '../../../src/screens/ModelDownloadScreen'; import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; import { resetStores } from '../../utils/testHelpers'; @@ -119,7 +119,7 @@ describe('Scan Network — alert matches the rendered list (device state-mismatc // and the follow-up auto-check marks it reachable a moment later. installFetch({ serverReachable: true, flakyWarmup: true }); - const ui = render(); + const ui = render(); // Wait for the "Analyzing your device..." init to finish and the network section to render. With an // empty store the mount auto-check settles immediately, so "Scan Network" is pressable. @@ -145,7 +145,7 @@ describe('Scan Network — alert matches the rendered list (device state-mismatc // No persisted server and nothing reachable on the LAN → the honest empty case. installFetch({ serverReachable: false }); - const ui = render(); + const ui = render(); await waitFor(() => { expect(ui.queryByText('Network Models')).not.toBeNull(); }, { timeout: 5000 }); fireEvent.press(ui.getByText('Scan Network')); diff --git a/__tests__/integration/onboarding/serverModelConfiguredSkipsOnboarding.test.tsx b/__tests__/integration/onboarding/serverModelConfiguredSkipsOnboarding.test.tsx index 83f386a50..d1d90213b 100644 --- a/__tests__/integration/onboarding/serverModelConfiguredSkipsOnboarding.test.tsx +++ b/__tests__/integration/onboarding/serverModelConfiguredSkipsOnboarding.test.tsx @@ -6,15 +6,14 @@ * model are already configured ('hit continue, it skipped onboarding — good UX')." This locks that happy * path as a regression guard. * - * Product-correct outcome (OGAM user's view): while on the ModelDownload onboarding step (the "remaining - * onboarding"), if the user connects to a network server that has a model, tapping "Continue" on the - * "Connected!" sheet drops them into the main app (the tab bar / Home) and does NOT leave them on — or - * bounce them back to — the ModelDownload onboarding step. + * Product-correct outcome (OGAM user's view): Auto Setup is the default onboarding step. A user who + * chooses "Configure it yourself" reaches Advanced Setup. If the user connects to a network server + * that has a model, tapping "Continue" on the "Connected!" sheet drops them into the main app. * * Entry point + gestures (real, arrive-via-UI): * - Mount the REAL AppNavigator inside a REAL NavigationContainer. With onboarding already completed but - * NO downloaded on-device model, the initial route is 'ModelDownload' — i.e. the remaining onboarding - * step the user still sees. + * NO downloaded on-device model, the initial route is Auto Setup. + * - Tap "Configure it yourself" to reach Advanced Setup. * - Add a server the real way: tap "Add manually", type a name + endpoint into the real modal, tap "Test * Connection" (the real probe runs over the faked /v1/models), then tap the modal's "Add manually" save. * - Back on the onboarding screen the real health check marks the server reachable and renders its @@ -37,6 +36,7 @@ */ import React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react-native'; +jest.unmock('@react-navigation/native'); import { NavigationContainer } from '@react-navigation/native'; // Safe-area infra (jsdom has no native safe-area). Presentation-only shim, not app logic — the same @@ -71,14 +71,17 @@ function installFetch(reachable: boolean) { }); } -/** Arrive-via-UI on the ModelDownload onboarding step, then add + connect a server through the real modal. */ +/** Enter Advanced Setup from Auto Setup, then add and connect a server through the real modal. */ async function addAndConnectServerViaUI(ui: ReturnType) { + fireEvent.press(await waitFor(() => ui.getByTestId('auto-setup-advanced'), { timeout: 4000 })); + await waitFor(() => { expect(ui.queryByTestId('model-download-screen')).not.toBeNull(); }, { timeout: 4000 }); + // Real gesture: open the Add Server modal from the onboarding screen. // The onboarding screen's own button, which is not the Remote Servers screen's one. fireEvent.press(await waitFor(() => ui.getByText('Add Server'))); // Fill the real modal (targeted by placeholders, like the RemoteServersScreen flow). - fireEvent.changeText(await waitFor(() => ui.getByPlaceholderText('e.g., Off Grid AI Desktop')), 'My Desktop'); + fireEvent.changeText(await waitFor(() => ui.getByPlaceholderText('Off Grid AI Desktop')), 'My Desktop'); fireEvent.changeText(ui.getByPlaceholderText('http://192.168.1.50:7878'), 'http://localhost:1234'); // Test Connection first — the real probe runs over the faked /v1/models. Save stays disabled until it @@ -88,6 +91,7 @@ async function addAndConnectServerViaUI(ui: ReturnType) { // Save the server (the modal's "Add manually", the last such text). fireEvent.press(ui.getByTestId('save-server')); + await waitFor(() => { expect(ui.queryByTestId('server-name')).toBeNull(); }, { timeout: 4000 }); // The onboarding screen's real health check now marks the server reachable → its Connect button renders. const connect = await waitFor(() => ui.getByTestId(/^discovered-server-.*-connect$/), { timeout: 4000 }); @@ -101,12 +105,12 @@ describe('T095 — server + model configured → tap Continue → routes into th // Fresh remote-server slate so the added server is the only row. useRemoteServerStore.setState({ servers: [], serverHealth: {}, discoveredModels: {} }); // Onboarding slides already done + NO on-device model downloaded → the initial route is the remaining - // onboarding step, 'ModelDownload' (per AppNavigator's initial-route logic). This is BOOT state, not a + // onboarding step, Auto Setup (per AppNavigator's initial-route logic). This is BOOT state, not a // fabrication of the tested outcome — the outcome (skipping to Main) is produced by the gestures below. useAppStore.setState({ hasCompletedOnboarding: true, downloadedModels: [], deviceInfo: createDeviceInfo() }); }); - it('lands on the main app (Home) and the ModelDownload onboarding step is gone after Continue', async () => { + it('moves from Auto Setup through Advanced Setup and lands on Home after a server connects', async () => { installFetch(true); // a reachable server with a model exists on the network const ui = render( @@ -114,8 +118,9 @@ describe('T095 — server + model configured → tap Continue → routes into th , ); - // Pre-condition: we ARE on the remaining onboarding step and NOT yet in the app. - await waitFor(() => { expect(ui.queryByTestId('model-download-screen')).not.toBeNull(); }, { timeout: 4000 }); + // Auto Setup is the default. Advanced Setup is not shown until the user asks for it. + await waitFor(() => { expect(ui.queryByTestId('auto-setup-screen')).not.toBeNull(); }, { timeout: 4000 }); + expect(ui.queryByTestId('model-download-screen')).toBeNull(); expect(ui.queryByTestId('home-tab')).toBeNull(); await addAndConnectServerViaUI(ui); diff --git a/__tests__/integration/pro/companionTaskRouting.integration.test.ts b/__tests__/integration/pro/companionTaskRouting.integration.test.ts new file mode 100644 index 000000000..c232391a4 --- /dev/null +++ b/__tests__/integration/pro/companionTaskRouting.integration.test.ts @@ -0,0 +1,181 @@ +import { McpToolExtension } from '@offgrid/pro/mcp/McpToolExtension'; +import { useMcpStore } from '@offgrid/pro/mcp/mcpStore'; +import { initCompanionTaskMesh } from '@offgrid/pro/mcp/companionTaskMesh'; +import { useSyncStore } from '@offgrid/pro/sync/syncStore'; +import { useRemoteServerStore } from '@offgrid/core/stores'; + +const mockMeshListeners: Array<(deviceId: string, channel: string, data: unknown) => void> = []; +const mockMeshSendApp = jest.fn(); + +jest.mock('@offgrid/pro/sync/syncService', () => ({ + syncService: { + onAppMessage: (listener: (deviceId: string, channel: string, data: unknown) => void) => { + mockMeshListeners.push(listener); + return () => { + const index = mockMeshListeners.indexOf(listener); + if (index >= 0) mockMeshListeners.splice(index, 1); + }; + }, + sendApp: (...args: unknown[]) => mockMeshSendApp(...args), + }, +})); + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +const computerTask = { + name: 'computer_use', + description: `Use the selected Desktop. ${'Route carefully. '.repeat(80)}`, + inputSchema: { + type: 'object', + properties: { + goal: { type: 'string', description: 'The work to complete.' }, + execution_device: { + type: 'string', + description: 'The connected Desktop name or alias.', + }, + notes: { type: 'string', description: 'Optional details.'.repeat(80) }, + }, + required: ['goal'], + }, +}; + +function addDesktop(serverId: string, deviceId: string, name: string) { + const store = useMcpStore.getState(); + store.addServer({ + id: serverId, + name, + url: `http://${serverId}/mcp`, + grantedByDeviceId: deviceId, + }); + store.setConnectionState(serverId, 'connected'); + store.setServerTools(serverId, [computerTask]); +} + +describe('Mobile companion task routing integration', () => { + let stopMesh: (() => void) | undefined; + + beforeEach(() => { + useSyncStore.getState().reset(); + useRemoteServerStore.getState().setActiveRemoteTextModelId(null); + addDesktop('office-tools', 'desktop-office', 'Office Mac'); + addDesktop('studio-tools', 'desktop-studio', 'Studio Mac'); + useMcpStore.getState().setEnabledTools(['computer_use']); + useSyncStore.getState().setThisDevice({ + id: 'phone-1', + name: 'Ali phone', + platform: 'ios', + version: '1', + host: '', + port: 0, + }); + useSyncStore.getState().setKnownDevices([ + { + id: 'desktop-office', + name: 'Office Mac', + platform: 'macos', + version: '1', + host: 'office', + port: 1, + status: 'connected', + pairedAt: 1, + lastSeenAt: 1, + }, + { + id: 'desktop-studio', + name: 'Studio Alias', + platform: 'macos', + version: '1', + host: 'studio', + port: 1, + status: 'connected', + pairedAt: 1, + lastSeenAt: 1, + }, + ]); + useSyncStore + .getState() + .setConnectedDeviceIds(['desktop-office', 'desktop-studio']); + mockMeshSendApp.mockReset().mockImplementation((deviceId, channel, data) => { + if (channel !== 'companion-task-call') return true; + const request = data as { requestId: string }; + queueMicrotask(() => { + for (const listener of mockMeshListeners) { + listener(deviceId, 'companion-task-result', { + version: 1, + requestId: request.requestId, + ok: true, + content: 'task started', + durationMs: 5, + }); + } + }); + return true; + }); + stopMesh = initCompanionTaskMesh(); + }); + + afterEach(() => { + stopMesh?.(); + useMcpStore.getState().removeServer('office-tools'); + useMcpStore.getState().removeServer('studio-tools'); + useSyncStore.getState().reset(); + }); + + it('keeps Desktop selection visible to the model and sends its canonical ID', async () => { + const schema = McpToolExtension.getOpenAISchemas!() as Array; + expect(schema).toHaveLength(1); + expect(schema[0].function.name).toBe('computer_use'); + expect(schema[0].function.parameters.properties).toHaveProperty( + 'execution_device', + ); + expect(schema[0].function.parameters.properties).not.toHaveProperty( + 'notes', + ); + + const result = await McpToolExtension.execute({ + id: 'task-call-1', + name: 'computer_use', + arguments: { + goal: 'Open the project plan.', + execution_device: 'studio alias', + }, + context: { conversationId: 'chat-mobile-1' }, + }); + + expect(result.error).toBeUndefined(); + expect(result.content).toBe('task started'); + expect(mockMeshSendApp).toHaveBeenCalledTimes(1); + expect(mockMeshSendApp).toHaveBeenCalledWith( + 'desktop-studio', + 'companion-task-call', + expect.objectContaining({ + version: 1, + requestId: expect.any(String), + name: 'computer_use', + args: { + goal: 'Open the project plan.', + execution_device: 'studio alias', + }, + origin: { + conversationId: 'chat-mobile-1', + launchId: expect.any(String), + deviceId: 'phone-1', + deviceName: 'Ali phone', + executionDeviceId: 'desktop-studio', + }, + }), + ); + }); +}); diff --git a/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx b/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx new file mode 100644 index 000000000..45836480d --- /dev/null +++ b/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { act, render } from '@testing-library/react-native'; +import type { NavigationContainerRef } from '@react-navigation/native'; +import { useProExpiryRedirect } from '../../../src/navigation/useProExpiryRedirect'; +import type { RootStackParamList } from '../../../src/navigation/types'; +import { useAppStore } from '../../../src/stores/appStore'; + +type TestNavigation = Pick< + NavigationContainerRef, + 'isReady' | 'resetRoot' +>; + +function Probe({ navigation }: { navigation: TestNavigation }): null { + useProExpiryRedirect(navigation); + return null; +} + +describe('the live Pro expiry redirect', () => { + const resetRoot = jest.fn(); + let ready = true; + const navigation: TestNavigation = { + isReady: () => ready, + resetRoot, + }; + + beforeEach(() => { + ready = true; + resetRoot.mockClear(); + useAppStore.setState({ + hasRegisteredPro: false, + hasSavedProCredential: false, + isProActive: false, + proDeviceAdmission: 'unknown', + }); + }); + + it('does not redirect a normal free launch', () => { + render(); + expect(resetRoot).not.toHaveBeenCalled(); + }); + + it('replaces the current route with the purchase screen when access is lost', () => { + useAppStore.setState({ + hasRegisteredPro: true, + hasSavedProCredential: true, + isProActive: true, + proDeviceAdmission: 'active', + }); + render(); + + act(() => { + useAppStore.setState({ + hasRegisteredPro: false, + hasSavedProCredential: false, + isProActive: false, + proDeviceAdmission: 'unknown', + }); + }); + + expect(resetRoot).toHaveBeenCalledTimes(1); + expect(resetRoot).toHaveBeenCalledWith({ + index: 0, + routes: [{ name: 'ProDetail' }], + }); + }); +}); diff --git a/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx b/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx new file mode 100644 index 000000000..c72799503 --- /dev/null +++ b/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx @@ -0,0 +1,51 @@ +/** + * The Pro pitch has one purchase path and one key-entry path. A second action that offered to use + * another device's licence duplicated the Sync journey and made the purchase screen ambiguous. + * + * This test enters through the real Home screen and real app navigation. Native rendering remains + * supplied by the Jest environment, but every Off Grid screen, store action, and route is real. + */ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { AppNavigator } from '../../../src/navigation/AppNavigator'; +import { useAppStore } from '../../../src/stores/appStore'; +import { createDeviceInfo, createDownloadedModel } from '../../utils/factories'; + +describe('Pro entry from Home', () => { + beforeEach(() => { + const model = createDownloadedModel(); + const app = useAppStore.getState(); + + // Use the production store actions to restore a returning user's local app state. The behavior + // under test starts with their real Home-screen gesture below. + app.setOnboardingComplete(true); + app.setDeviceInfo(createDeviceInfo()); + app.setDownloadedModels([model]); + app.setActiveModelId(model.id); + }); + + afterEach(() => { + const app = useAppStore.getState(); + app.setActiveModelId(null); + app.setDownloadedModels([]); + app.setOnboardingComplete(false); + }); + + it('opens the Pro screen with its two valid actions and no device-licence shortcut', async () => { + const ui = render( + + + , + ); + + fireEvent.press(await ui.findByLabelText('Open Off Grid AI Pro')); + + await waitFor(() => expect(ui.getByText('Off Grid AI Pro')).toBeTruthy()); + expect(ui.getAllByText('Get Pro').length).toBeGreaterThan(0); + expect(ui.getByText('I have a license key')).toBeTruthy(); + expect(ui.queryByText('Use Pro from another device')).toBeNull(); + + ui.unmount(); + }); +}); diff --git a/__tests__/integration/pro/release107TaskControlAcknowledgement.rendered.redflow.test.tsx b/__tests__/integration/pro/release107TaskControlAcknowledgement.rendered.redflow.test.tsx new file mode 100644 index 000000000..8850b973b --- /dev/null +++ b/__tests__/integration/pro/release107TaskControlAcknowledgement.rendered.redflow.test.tsx @@ -0,0 +1,275 @@ +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react-native'; +import { TASK_RUN_ENTITY, type SyncedTaskRun } from '@offgrid/sync'; +import { TaskChatCard } from '../../../pro/ui/TaskChatCard'; +import { MobileStateMaterializer } from '../../../pro/sync/mobileStateMaterializer'; +import { + TASK_CONTROL_ACK_TIMEOUT_MS, + useTaskRunStore, +} from '../../../pro/tasks/taskRunStore'; +import { useSyncStore } from '../../../pro/sync/syncStore'; +import { useChatStore } from '../../../src/stores/chatStore'; + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +const materializer = new MobileStateMaterializer(); +const origin = { + originDeviceId: 'desktop-release-107', + originDeviceName: 'Office Mac', +}; + +function runningTask(kind: SyncedTaskRun['kind']): SyncedTaskRun { + return { + version: 1, + launchId: `launch-release-107-${kind}`, + requestingDeviceId: 'mobile-device', + taskId: `release-107-${kind}`, + conversationId: `release-107-chat-${kind}`, + kind, + executionDevice: { + id: origin.originDeviceId, + name: origin.originDeviceName, + }, + title: + kind === 'web_use' + ? 'Check the release status' + : 'Open the release build', + status: 'running', + phase: 'acting', + currentAction: 'Working on the task', + progress: [], + startedAt: 10, + updatedAt: 20, + }; +} + +function renderTask(run: SyncedTaskRun): ReturnType { + materializer.put( + 'conversation', + run.conversationId, + { + title: run.title, + created_at: new Date(run.startedAt).toISOString(), + updated_at: new Date(run.updatedAt).toISOString(), + project_id: null, + }, + origin, + ); + useChatStore.getState().setActiveConversation(run.conversationId); + materializer.put( + TASK_RUN_ENTITY, + run.taskId, + run as unknown as Record, + origin, + ); + return render( + , + ); +} + +describe('Release 107 rendered task-control acknowledgement', () => { + beforeEach(() => { + jest.useFakeTimers(); + useChatStore.getState().clearAllConversations(); + useSyncStore.getState().setThisDevice({ + id: 'mobile-release-107', + name: 'Release phone', + platform: 'ios', + version: '107', + host: '', + port: 0, + }); + useSyncStore.getState().setConnectedDeviceIds([origin.originDeviceId]); + }); + + afterEach(() => { + for (const kind of ['web_use', 'computer_use'] as const) { + materializer.remove(TASK_RUN_ENTITY, runningTask(kind).taskId); + } + jest.useRealTimers(); + }); + + it.each(['web_use', 'computer_use'] as const)( + 'keeps a %s request pending through unrelated updates, clears it on a matching acknowledgement, and bounds the next wait', + async kind => { + const run = runningTask(kind); + const screen = renderTask(run); + + await act(async () => { + fireEvent.press(screen.getByTestId('task-control-pause')); + }); + const pauseRequest = + useTaskRunStore.getState().requestedControlByTaskId[run.taskId]; + expect(pauseRequest?.controlId).toBeTruthy(); + expect(screen.getByText('Pause requested')).toBeTruthy(); + expect( + screen.getByTestId('task-control-stop').props.accessibilityState, + ).toMatchObject({ + disabled: true, + }); + + act(() => { + materializer.put( + TASK_RUN_ENTITY, + run.taskId, + { + ...run, + currentAction: 'Opened another page', + updatedAt: 30, + latestControlResult: { + controlId: 'another-control', + kind: 'pause', + outcome: 'applied', + respondedAt: 30, + }, + }, + origin, + ); + }); + expect(screen.getByText('Pause requested')).toBeTruthy(); + + act(() => { + materializer.put( + TASK_RUN_ENTITY, + run.taskId, + { + ...run, + status: 'paused', + phase: 'paused', + updatedAt: 40, + latestControlResult: { + controlId: pauseRequest!.controlId, + kind: 'pause', + outcome: 'applied', + respondedAt: 40, + }, + }, + origin, + ); + }); + expect(screen.queryByText('Pause requested')).toBeNull(); + expect(screen.getByText('Resume')).toBeTruthy(); + + await act(async () => { + fireEvent.press(screen.getByTestId('task-control-stop')); + }); + const rejectedRequest = + useTaskRunStore.getState().requestedControlByTaskId[run.taskId]; + expect(screen.getByText('Stop requested')).toBeTruthy(); + + act(() => { + materializer.put( + TASK_RUN_ENTITY, + run.taskId, + { + ...run, + status: 'paused', + phase: 'paused', + updatedAt: 50, + latestControlResult: { + controlId: rejectedRequest!.controlId, + kind: 'stop', + outcome: 'rejected', + message: + 'The task owner rejected Stop because the run already changed.', + respondedAt: 50, + }, + }, + origin, + ); + }); + expect( + screen.getByText( + 'The task owner rejected Stop because the run already changed.', + ), + ).toBeTruthy(); + + await act(async () => { + fireEvent.press(screen.getByTestId('task-control-stop')); + }); + expect(screen.getByText('Stop requested')).toBeTruthy(); + + act(() => { + jest.advanceTimersByTime(TASK_CONTROL_ACK_TIMEOUT_MS); + }); + expect( + screen.getByText( + 'Off Grid AI Desktop did not confirm the stop request on Office Mac within 15 seconds. Try again.', + ), + ).toBeTruthy(); + expect( + screen.getByTestId('task-control-stop').props.accessibilityState, + ).toMatchObject({ + disabled: false, + }); + }, + ); + + it.each(['web_use', 'computer_use'] as const)( + 'continues a waiting %s task after the user completes the step on its Mac', + async kind => { + const waiting: SyncedTaskRun = { + ...runningTask(kind), + status: 'waiting', + phase: 'waiting', + currentAction: 'Sign in on Office Mac', + }; + const screen = renderTask(waiting); + + expect(screen.getByText('Continue')).toBeTruthy(); + expect(screen.getByText(/Complete the requested step on Office Mac/)).toBeTruthy(); + expect(screen.getByText(/Your phone does not take control of the Mac/)).toBeTruthy(); + expect(screen.queryByText('Take Over')).toBeNull(); + + await act(async () => { + fireEvent.press(screen.getByTestId('task-control-continue')); + }); + const request = useTaskRunStore.getState().requestedControlByTaskId[waiting.taskId]; + expect(request).toMatchObject({ kind: 'resume', label: 'Continue', state: 'pending' }); + expect(screen.getByText('Continue requested')).toBeTruthy(); + + act(() => { + materializer.put( + TASK_RUN_ENTITY, + waiting.taskId, + { + ...waiting, + status: 'running', + phase: 'acting', + currentAction: 'Continuing after sign-in', + updatedAt: 30, + latestControlResult: { + controlId: request!.controlId, + kind: 'resume', + outcome: 'applied', + respondedAt: 30, + }, + }, + origin, + ); + }); + + expect(screen.queryByText('Continue requested')).toBeNull(); + expect(screen.getByText('Continuing after sign-in')).toBeTruthy(); + expect(screen.queryByTestId('task-handoff-guidance')).toBeNull(); + }, + ); +}); diff --git a/__tests__/integration/pro/release107TaskSessionPlayback.rendered.test.tsx b/__tests__/integration/pro/release107TaskSessionPlayback.rendered.test.tsx new file mode 100644 index 000000000..f15269a9e --- /dev/null +++ b/__tests__/integration/pro/release107TaskSessionPlayback.rendered.test.tsx @@ -0,0 +1,379 @@ +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react-native'; +import { + TASK_RUN_ENTITY, + TASK_VISUAL_STEP_ENTITY, + taskVisualStepId, + type SyncedTaskRun, + type SyncedTaskVisualStep, +} from '@offgrid/sync'; +import { MobileStateMaterializer } from '../../../pro/sync/mobileStateMaterializer'; +import { isRequiredSyncEntity } from '../../../pro/sync/requiredSyncEntity'; +import { useSyncStore } from '../../../pro/sync/syncStore'; +import { TaskChatCard } from '../../../pro/ui/TaskChatCard'; +import { + SLOTS, + _clearSlotsForTesting, + registerSlot, +} from '../../../src/bootstrap/slotRegistry'; +import { ChatMessage } from '../../../src/components/ChatMessage'; +import { useAccordionStore } from '../../../src/stores/accordionStore'; +import { useChatStore } from '../../../src/stores/chatStore'; + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +jest.mock('@react-native-community/slider', () => ({ + __esModule: true, + default: (props: Record) => + require('react').createElement(require('react-native').View, props), +})); + +const materializer = new MobileStateMaterializer(); +const origin = { + originDeviceId: 'desktop-session-owner', + originDeviceName: 'Studio Mac', +}; +const FRAME_PAYLOAD = '/9j/2Q=='; + +const taskToolName = (kind: SyncedTaskRun['kind']): string => + kind === 'computer_use' ? 'computer_use' : kind; + +function taskRun( + kind: SyncedTaskRun['kind'], + status: SyncedTaskRun['status'], +): SyncedTaskRun { + const taskId = `release-107-session-${kind}-${status}`; + return { + version: 1, + launchId: `launch-${taskId}`, + requestingDeviceId: 'mobile-device', + taskId, + conversationId: `${taskId}-chat`, + kind, + executionDevice: { id: origin.originDeviceId, name: 'Studio Mac' }, + title: kind === 'web_use' ? 'Review the site' : 'Review the desktop app', + status, + phase: + status === 'running' + ? 'acting' + : status === 'done' + ? 'complete' + : status === 'reconnecting' + ? 'waiting' + : status, + progress: [], + startedAt: 1_000, + updatedAt: 4_000, + finishedAt: status === 'running' ? undefined : 4_000, + ...(status === 'running' + ? { + frame: { + sequence: 3, + mimeType: 'image/jpeg' as const, + payloadBase64: FRAME_PAYLOAD, + width: 100, + height: 50, + capturedAt: 4_000, + }, + } + : {}), + }; +} + +function visualStep( + run: SyncedTaskRun, + sequence: number, +): SyncedTaskVisualStep { + return { + version: 1, + visualStepId: taskVisualStepId(run.taskId, sequence), + taskId: run.taskId, + conversationId: run.conversationId, + sequence, + executionDevice: run.executionDevice, + phase: sequence === 1 ? 'observing' : 'acting', + actionLabel: sequence === 1 ? 'Opened the target' : 'Selected Continue', + cursor: sequence === 2 ? { x: 75, y: 25 } : undefined, + frame: { + sequence, + mimeType: 'image/jpeg', + payloadBase64: FRAME_PAYLOAD, + width: 100, + height: 50, + capturedAt: sequence === 1 ? 2_000 : 5_500, + }, + ...(sequence === 2 ? { result: { status: 'complete' as const } } : {}), + }; +} + +function renderSyncedTask(run: SyncedTaskRun): ReturnType { + materializer.put( + 'conversation', + run.conversationId, + { + title: run.title, + created_at: new Date(run.startedAt).toISOString(), + updated_at: new Date(run.updatedAt).toISOString(), + project_id: null, + }, + origin, + ); + useChatStore.getState().setActiveConversation(run.conversationId); + materializer.put( + TASK_RUN_ENTITY, + run.taskId, + run as unknown as Record, + origin, + ); + // Admitted sync puts the authenticated run owner before its evidence. + for (const sequence of [2, 1]) { + const step = visualStep(run, sequence); + materializer.put( + TASK_VISUAL_STEP_ENTITY, + step.visualStepId, + step as unknown as Record, + origin, + ); + } + return render( + , + ); +} + +function removeSyncedTask(run: SyncedTaskRun): void { + materializer.remove(TASK_RUN_ENTITY, run.taskId); + for (const sequence of [1, 2]) { + materializer.remove( + TASK_VISUAL_STEP_ENTITY, + taskVisualStepId(run.taskId, sequence), + ); + } +} + +function measureTaskSessionFrame(screen: ReturnType): void { + fireEvent(screen.getByTestId('task-session-frame'), 'layout', { + nativeEvent: { layout: { width: 300 } }, + }); +} + +describe('Release 107 task session playback', () => { + beforeEach(() => { + jest.useFakeTimers(); + registerSlot(SLOTS.taskToolDetail, TaskChatCard); + useAccordionStore.setState({ expanded: {} }); + useChatStore.getState().clearAllConversations(); + useSyncStore.getState().setConnectedDeviceIds([origin.originDeviceId]); + }); + + afterEach(() => { + for (const kind of ['web_use', 'computer_use'] as const) { + for (const status of ['running', 'done', 'stopped'] as const) { + removeSyncedTask(taskRun(kind, status)); + } + } + _clearSlotsForTesting(); + jest.useRealTimers(); + }); + + it('keeps saved task frames in required state sync', () => { + expect(isRequiredSyncEntity(TASK_VISUAL_STEP_ENTITY)).toBe(true); + }); + + it.each([ + ['web_use', 'done'], + ['computer_use', 'stopped'], + ] as const)( + 'plays and scrubs the preserved %s session after it is %s', + (kind, status) => { + const screen = renderSyncedTask(taskRun(kind, status)); + + expect(screen.queryByTestId('task-chat-card')).toBeNull(); + expect(screen.queryByText('Raw task plan must stay hidden.')).toBeNull(); + fireEvent.press( + screen.getByTestId(`tool-result-accordion-${taskToolName(kind)}`), + ); + expect(screen.getByTestId('task-session-playback')).toBeTruthy(); + expect(screen.getByText('Step 1 of 2 · 0:00 / 0:03')).toBeTruthy(); + expect(screen.getByText('Opened the target')).toBeTruthy(); + expect(screen.queryByTestId('task-control-stop')).toBeNull(); + expect(screen.queryByText('The task screen is syncing.')).toBeNull(); + + fireEvent( + screen.getByTestId('task-session-frame'), + 'layout', + { nativeEvent: { layout: { width: 300 } } }, + ); + expect(screen.getByLabelText('Saved task screen 1 of 2')).toBeTruthy(); + expect(screen.queryByTestId('task-live-frame')).toBeNull(); + + fireEvent( + screen.getByTestId('task-session-scrubber'), + 'valueChange', + 1, + ); + expect(screen.getByText('Step 2 of 2 · 0:03 / 0:03')).toBeTruthy(); + expect(screen.getByText('Selected Continue')).toBeTruthy(); + expect(screen.getByTestId('task-session-cursor')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('task-session-toggle')); + expect(screen.getByText('Pause')).toBeTruthy(); + act(() => jest.advanceTimersByTime(3_499)); + expect(screen.getByText('Step 1 of 2 · 0:00 / 0:03')).toBeTruthy(); + act(() => jest.advanceTimersByTime(1)); + expect(screen.getByText('Step 2 of 2 · 0:03 / 0:03')).toBeTruthy(); + expect(screen.getByText('Play')).toBeTruthy(); + + fireEvent.press( + screen.getByTestId(`tool-result-accordion-${taskToolName(kind)}`), + ); + expect(screen.queryByTestId('task-session-playback')).toBeNull(); + }, + ); + + it.each(['web_use', 'computer_use'] as const)( + 'keeps the live %s frame while saved steps remain reviewable', + kind => { + const screen = renderSyncedTask(taskRun(kind, 'running')); + + expect(screen.queryByTestId('task-live-frame')).toBeNull(); + fireEvent.press( + screen.getByTestId(`tool-result-accordion-${taskToolName(kind)}`), + ); + expect(screen.getByTestId('task-session-playback')).toBeTruthy(); + expect(screen.getByText('LIVE VIEW')).toBeTruthy(); + expect(screen.getByTestId('task-session-frame')).toBeTruthy(); + measureTaskSessionFrame(screen); + expect(screen.getByLabelText('Live view from Studio Mac')).toBeTruthy(); + expect(screen.queryByTestId('task-live-frame')).toBeNull(); + expect(screen.getByTestId('task-control-stop')).toBeTruthy(); + }, + ); + + it.each(['web_use', 'computer_use'] as const)( + 'uses saved %s evidence while an active live frame is unavailable', + kind => { + const { frame: _liveFrame, ...run } = taskRun(kind, 'running'); + const screen = renderSyncedTask(run); + + fireEvent.press( + screen.getByTestId(`tool-result-accordion-${taskToolName(kind)}`), + ); + expect(screen.queryByText('The task screen is syncing.')).toBeNull(); + expect(screen.getByTestId('task-session-frame')).toBeTruthy(); + measureTaskSessionFrame(screen); + expect(screen.getByLabelText('Live view from Studio Mac')).toBeTruthy(); + expect(screen.queryByTestId('task-live-frame')).toBeNull(); + }, + ); + + it('replaces the sync placeholder as soon as saved evidence materializes', () => { + const { frame: _liveFrame, ...run } = taskRun('web_use', 'running'); + materializer.put( + 'conversation', + run.conversationId, + { + title: run.title, + created_at: new Date(run.startedAt).toISOString(), + updated_at: new Date(run.updatedAt).toISOString(), + project_id: null, + }, + origin, + ); + useChatStore.getState().setActiveConversation(run.conversationId); + materializer.put( + TASK_RUN_ENTITY, + run.taskId, + run as unknown as Record, + origin, + ); + const screen = render( + , + ); + + fireEvent.press(screen.getByTestId('tool-result-accordion-web_use')); + expect(screen.getByText('The task screen is syncing.')).toBeTruthy(); + expect(screen.getByTestId('task-frame-loading')).toBeTruthy(); + + const step = visualStep(run, 1); + act(() => { + materializer.put( + TASK_VISUAL_STEP_ENTITY, + step.visualStepId, + step as unknown as Record, + origin, + ); + }); + expect(screen.queryByText('The task screen is syncing.')).toBeNull(); + expect(screen.queryByTestId('task-frame-loading')).toBeNull(); + expect(screen.getByTestId('task-session-playback')).toBeTruthy(); + expect(screen.getByTestId('task-session-frame')).toBeTruthy(); + measureTaskSessionFrame(screen); + expect(screen.getByLabelText('Live view from Studio Mac')).toBeTruthy(); + expect(screen.queryByTestId('task-live-frame')).toBeNull(); + }); + + it('shows an active task inside its synced live-tool accordion', () => { + const run = taskRun('web_use', 'running'); + renderSyncedTask(run).unmount(); + const screen = render( + , + ); + + expect(screen.queryByTestId('task-chat-card')).toBeNull(); + expect(screen.getByText('Using Web Use...')).toBeTruthy(); + fireEvent.press(screen.getByTestId('tool-result-accordion-web_use')); + expect(screen.getByTestId('task-session-frame')).toBeTruthy(); + measureTaskSessionFrame(screen); + expect(screen.getByLabelText('Live view from Studio Mac')).toBeTruthy(); + expect(screen.queryByTestId('task-live-frame')).toBeNull(); + expect(screen.getByTestId('task-control-stop')).toBeTruthy(); + }); +}); diff --git a/__tests__/integration/pro/release107TaskStateIntegrity.test.ts b/__tests__/integration/pro/release107TaskStateIntegrity.test.ts new file mode 100644 index 000000000..d6a5cf77e --- /dev/null +++ b/__tests__/integration/pro/release107TaskStateIntegrity.test.ts @@ -0,0 +1,84 @@ +import { + MAX_SYNCED_TASK_VISUAL_STEPS, + type SyncedTaskRun, + type SyncedTaskVisualStep, +} from '@offgrid/sync'; +import { requestTaskControl } from '../../../pro/tasks/taskControlService'; +import { + useTaskRunStore, + visualStepsForTask, +} from '../../../pro/tasks/taskRunStore'; + +const run: SyncedTaskRun = { + version: 1, + launchId: 'launch-release-107-integrity', + requestingDeviceId: 'mobile-release-107', + taskId: 'task-release-107-integrity', + conversationId: 'chat-release-107-integrity', + kind: 'computer_use', + executionDevice: { id: 'desktop-release-107', name: 'Office Mac' }, + title: 'Send the release update', + status: 'running', + progress: [], + startedAt: 1, + updatedAt: 1, +}; + +function step(sequence: number): SyncedTaskVisualStep { + return { + version: 1, + visualStepId: `${run.taskId}:${sequence}`, + taskId: run.taskId, + conversationId: run.conversationId, + sequence, + executionDevice: run.executionDevice, + frame: { + sequence, + mimeType: 'image/jpeg', + payloadBase64: 'c2NyZWVu', + width: 100, + height: 50, + capturedAt: sequence * 100, + }, + }; +} + +describe('Release 107 Mobile task state integrity', () => { + beforeEach(() => { + useTaskRunStore.setState({ + runs: {}, + visualSteps: {}, + requestedControlByTaskId: {}, + }); + }); + + it('keeps the newest 250 recorded frames and removes them with their task', () => { + const store = useTaskRunStore.getState(); + store.applySynced(run); + for (let sequence = 1; sequence <= 251; sequence += 1) { + useTaskRunStore.getState().applyVisualStep(step(sequence)); + } + + const saved = visualStepsForTask( + useTaskRunStore.getState().visualSteps, + run.taskId, + ); + expect(saved).toHaveLength(MAX_SYNCED_TASK_VISUAL_STEPS); + expect(saved[0]?.sequence).toBe(2); + expect(saved.at(-1)?.sequence).toBe(251); + + useTaskRunStore.getState().remove(run.taskId); + expect(useTaskRunStore.getState().runs[run.taskId]).toBeUndefined(); + expect( + visualStepsForTask(useTaskRunStore.getState().visualSteps, run.taskId), + ).toEqual([]); + }); + + it('rejects a control from a stale rendered task state immediately', async () => { + useTaskRunStore.getState().applySynced({ ...run, updatedAt: 2 }); + + await expect(requestTaskControl(run, 'pause')).rejects.toThrow( + 'This task changed. Wait for its current state and try again.', + ); + }); +}); diff --git a/__tests__/integration/settings/modelSettingsSurfaceParity.test.tsx b/__tests__/integration/settings/modelSettingsSurfaceParity.test.tsx index 4f978cdb9..b199accff 100644 --- a/__tests__/integration/settings/modelSettingsSurfaceParity.test.tsx +++ b/__tests__/integration/settings/modelSettingsSurfaceParity.test.tsx @@ -120,6 +120,23 @@ describe('model settings surface parity', () => { expect(modelSettings.getByText('Base')).toBeTruthy(); }); + it('uses one STT language setting in chat settings and the Models screen', () => { + useWhisperStore.setState({ downloadedModelId: 'base', transcriptionLanguage: 'auto' }); + const chatSettings = render( + {}} />, + ); + fireEvent.press(chatSettings.getByTestId('modal-transcription-accordion')); + fireEvent.press(chatSettings.getByTestId('chat-transcription-language')); + fireEvent.press(chatSettings.getByTestId('chat-transcription-language-fr')); + expect(useWhisperStore.getState().transcriptionLanguage).toBe('fr'); + chatSettings.unmount(); + + const { TranscriptionModelsTab } = require('../../../src/screens/ModelsScreen/TranscriptionModelsTab'); + const models = render(); + expect(models.getByTestId('models-transcription-language').props.accessibilityLabel) + .toBe('Language: French'); + }); + it('renders the same TTS settings owner in both UI containers', () => { const SharedTtsSettings = () => ( Shared TTS settings diff --git a/__tests__/integration/stores/remoteServerDiscovery.test.ts b/__tests__/integration/stores/remoteServerDiscovery.test.ts index 102151371..c2c7e2f31 100644 --- a/__tests__/integration/stores/remoteServerDiscovery.test.ts +++ b/__tests__/integration/stores/remoteServerDiscovery.test.ts @@ -28,6 +28,7 @@ jest.mock('../../../src/services/httpClient', () => ({ })); import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; +import { detectServerType, testEndpoint } from '../../../src/services/httpClient'; // --------------------------------------------------------------------------- // Helpers @@ -568,6 +569,36 @@ describe('remoteServerDiscovery integration', () => { expect(models.some((m) => m.id === 'whisper-base')).toBe(false); }); + it('records active media models when it tests a named Desktop', async () => { + addServer({ id: 'srv-gw', endpoint: 'http://192.168.1.44:7878', name: 'Studio Mac' }); // NOSONAR + (testEndpoint as jest.Mock).mockResolvedValue({ success: true, latency: 8 }); + (detectServerType as jest.Mock).mockResolvedValue({ type: 'off-grid-desktop' }); + mockFetch.mockImplementation((url: string) => { + if (url.endsWith('/v1/models')) { + return Promise.resolve(jsonResponse({ + object: 'list', + data: [ + { id: 'gemma-3', kind: 'chat' }, + { id: 'sdxl', kind: 'image' }, + { id: 'kokoro', kind: 'speech' }, + { id: 'whisper-base', kind: 'transcription' }, + ], + })); + } + return Promise.resolve(jsonResponse({}, false, 404)); + }); + + const result = await useRemoteServerStore.getState().testConnection('srv-gw'); + + expect(result.mediaModels).toEqual({ + image: 'sdxl', + transcription: 'whisper-base', + voice: 'kokoro', + }); + expect(useRemoteServerStore.getState().getServerById('srv-gw')?.mediaModels) + .toEqual(result.mediaModels); + }); + it('still lists models from servers that do not send kind (Ollama/LM Studio)', async () => { addServer({ id: 'srv-plain', endpoint: 'http://192.168.1.20:1234' }); // NOSONAR diff --git a/__tests__/integration/stores/tts.test.ts b/__tests__/integration/stores/tts.test.ts index 06bff9129..075441e6c 100644 --- a/__tests__/integration/stores/tts.test.ts +++ b/__tests__/integration/stores/tts.test.ts @@ -171,10 +171,17 @@ describe('TTS integration', () => { describe('Chat Mode: speak → stop', () => { it('completes the full Chat Mode flow', async () => { + let finishSpeaking!: () => void; + mockEngine.speak.mockImplementationOnce( + () => new Promise(resolve => { finishSpeaking = resolve; }), + ); + // Speak const speakPromise = getState().speak('hello', 'msg1'); + await Promise.resolve(); // let the remote-voice preflight choose the local engine expect(getState().currentMessageId).toBe('msg1'); + finishSpeaking(); await speakPromise; expect(mockEngine.speak).toHaveBeenCalledWith('hello', expect.objectContaining({ speed: 1.0, diff --git a/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts b/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts index 1be9ea555..45d2f2766 100644 --- a/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts +++ b/__tests__/pro/audio/engines/KokoroEngine.extra.test.ts @@ -24,9 +24,11 @@ import { type KokoroBridgeHandle, } from '@offgrid/pro/audio/engine/tts/engines/kokoro/KokoroEngine'; -const fetchResources = (BareResourceFetcher as unknown as { fetch: jest.Mock }).fetch; +const fetchResources = (BareResourceFetcher as unknown as { fetch: jest.Mock }) + .fetch; const deleteResources = BareResourceFetcher.deleteResources as jest.Mock; -const listDownloadedFiles = BareResourceFetcher.listDownloadedFiles as jest.Mock; +const listDownloadedFiles = + BareResourceFetcher.listDownloadedFiles as jest.Mock; function makeHandle(): jest.Mocked { return { @@ -50,7 +52,7 @@ describe('KokoroEngine.extra — uncovered branches', () => { afterEach(() => { // No pollution: restore every Platform/console spy this file installed. - spies.forEach((s) => s.mockRestore()); + spies.forEach(s => s.mockRestore()); spies.length = 0; jest.restoreAllMocks(); // restores any jest.replaceProperty (Platform / executorch exports) jest.useRealTimers(); @@ -67,7 +69,9 @@ describe('KokoroEngine.extra — uncovered branches', () => { isFinal: true, }; let received: typeof chunk | null = null; - engine.on('audioChunk', (d) => { received = d; }); + engine.on('audioChunk', d => { + received = d; + }); engine._onAudioChunk(chunk); @@ -77,7 +81,9 @@ describe('KokoroEngine.extra — uncovered branches', () => { it('_onPlaybackTick forwards the elapsed seconds to playbackTick listeners', () => { const engine = new KokoroEngine(); let secs = -1; - engine.on('playbackTick', (s) => { secs = s; }); + engine.on('playbackTick', s => { + secs = s; + }); engine._onPlaybackTick(4.5); @@ -88,14 +94,22 @@ describe('KokoroEngine.extra — uncovered branches', () => { const engine = new KokoroEngine(); engine._setBridge(makeHandle(), 'af_heart'); expect(engine.getPhase()).toBe('ready'); - const errors: Array<{ code: string; message: string; recoverable: boolean }> = []; - engine.on('error', (e) => errors.push(e)); + const errors: Array<{ + code: string; + message: string; + recoverable: boolean; + }> = []; + engine.on('error', e => errors.push(e)); engine._onBridgeError('runtime exploded'); expect(engine.getPhase()).toBe('error'); expect(errors).toEqual([ - { code: 'KOKORO_RUNTIME', message: 'runtime exploded', recoverable: false }, + { + code: 'KOKORO_RUNTIME', + message: 'runtime exploded', + recoverable: false, + }, ]); // Bridge was cleared: stop() from now on can't reach a handle and phase stays 'error'. engine.stop(); @@ -105,7 +119,7 @@ describe('KokoroEngine.extra — uncovered branches', () => { it('_setDownloadProgress emits downloadProgress AND flips idle→downloading only on a fractional tick', () => { const engine = new KokoroEngine(); const events: number[] = []; - engine.on('downloadProgress', (d) => events.push(d.progress)); + engine.on('downloadProgress', d => events.push(d.progress)); // Fractional from idle → downloading (guard TRUE side). engine._setDownloadProgress(0.5); @@ -150,7 +164,9 @@ describe('KokoroEngine.extra — uncovered branches', () => { it('speak() rejects with a timeout when the requested mount never attaches', async () => { jest.useFakeTimers(); const engine = new KokoroEngine(); - engine._setMountRequester(() => {/* never calls _setBridge */}); + engine._setMountRequester(() => { + /* never calls _setBridge */ + }); const p = engine.speak('hello').catch((e: Error) => e); await jest.advanceTimersByTimeAsync(15000); @@ -204,9 +220,11 @@ describe('KokoroEngine.extra — uncovered branches', () => { it('setVoice on an unknown id throws and does not emit voiceChanged', async () => { const engine = new KokoroEngine(); const changed: string[] = []; - engine.on('voiceChanged', (id) => changed.push(id)); + engine.on('voiceChanged', id => changed.push(id)); - await expect(engine.setVoice('no_such_voice')).rejects.toThrow(/Unknown Kokoro voice/); + await expect(engine.setVoice('no_such_voice')).rejects.toThrow( + /Unknown Kokoro voice/, + ); expect(changed).toEqual([]); expect(engine.getActiveVoice()?.id).toBe('af_heart'); // unchanged }); @@ -214,7 +232,10 @@ describe('KokoroEngine.extra — uncovered branches', () => { it('setVoice on a valid id fetches its assets, records completion and emits voiceChanged', async () => { const engine = new KokoroEngine(); const changed: string[] = []; - engine.on('voiceChanged', (id) => changed.push(id)); + engine.on('voiceChanged', id => changed.push(id)); + engine._setMountRequester(() => { + engine._setBridge(makeHandle(), 'bm_daniel'); + }); await engine.setVoice('bm_daniel'); @@ -223,17 +244,17 @@ describe('KokoroEngine.extra — uncovered branches', () => { expect(changed).toEqual(['bm_daniel']); }); - it('setVoice still emits voiceChanged (and switches) when the asset fetch fails', async () => { + it('setVoice rejects and keeps the usable voice when the asset fetch fails', async () => { const engine = new KokoroEngine(); fetchResources.mockRejectedValueOnce(new Error('net down')); const changed: string[] = []; - engine.on('voiceChanged', (id) => changed.push(id)); + engine.on('voiceChanged', id => changed.push(id)); - await engine.setVoice('am_adam'); // must not reject; failure is warn-and-continue + await expect(engine.setVoice('am_adam')).rejects.toThrow('net down'); - expect(engine.getActiveVoice()?.id).toBe('am_adam'); + expect(engine.getActiveVoice()?.id).toBe('af_heart'); expect(engine.isFullyDownloaded()).toBe(false); // no completion recorded on failure - expect(changed).toEqual(['am_adam']); + expect(changed).toEqual([]); }); // ── speak retry / error / session ownership ─────────────────────────────── @@ -262,7 +283,7 @@ describe('KokoroEngine.extra — uncovered branches', () => { handle.speak.mockRejectedValue(new Error('engine on fire')); engine._setBridge(handle, 'af_heart'); const errors: Array<{ code: string; recoverable: boolean }> = []; - engine.on('error', (e) => errors.push(e)); + engine.on('error', e => errors.push(e)); await expect(engine.speak('boom')).rejects.toThrow('engine on fire'); @@ -293,12 +314,18 @@ describe('KokoroEngine.extra — uncovered branches', () => { const handle = makeHandle(); let releaseFirst!: () => void; handle.speak - .mockImplementationOnce(() => new Promise((r) => { releaseFirst = r; })) + .mockImplementationOnce( + () => + new Promise(r => { + releaseFirst = r; + }), + ) .mockResolvedValueOnce(undefined); engine._setBridge(handle, 'af_heart'); - const first = engine.speak('one'); // opens session 1 (pending) - const second = await engine.speak('two') // session 2 runs and completes + const first = engine.speak('one'); // opens session 1 (pending) + const second = await engine + .speak('two') // session 2 runs and completes .then(() => 'second-done'); expect(second).toBe('second-done'); expect(engine.getPhase()).toBe('ready'); @@ -314,7 +341,7 @@ describe('KokoroEngine.extra — uncovered branches', () => { const engine = new KokoroEngine(); fetchResources.mockRejectedValueOnce(new Error('disk full')); const errors: Array<{ code: string; recoverable: boolean }> = []; - engine.on('error', (e) => errors.push(e)); + engine.on('error', e => errors.push(e)); await expect(engine.downloadAssets()).rejects.toThrow('disk full'); @@ -354,7 +381,9 @@ describe('KokoroEngine.extra — uncovered branches', () => { fetchResources.mockImplementationOnce(async () => { // The concurrent fetch has finished writing every byte to the shared cache; the // engine learns this and latches completion, then the losing fetch throws. - (engine as unknown as { _genuineCompletion: boolean })._genuineCompletion = true; + ( + engine as unknown as { _genuineCompletion: boolean } + )._genuineCompletion = true; throw new Error('Resource already downloading'); }); @@ -381,7 +410,9 @@ describe('KokoroEngine.extra — uncovered branches', () => { it('generateAndSave rejects — Kokoro has no generate-and-save capability', async () => { const engine = new KokoroEngine(); expect(engine.capabilities.generateAndSave).toBe(false); - await expect(engine.generateAndSave()).rejects.toThrow(/does not support generateAndSave/); + await expect(engine.generateAndSave()).rejects.toThrow( + /does not support generateAndSave/, + ); }); it('pause/resume drive processing↔paused only from the matching phase', () => { @@ -395,7 +426,9 @@ describe('KokoroEngine.extra — uncovered branches', () => { expect(engine.getPhase()).toBe('ready'); // put it into processing via the private setter path (speak), then pause↔resume. - (engine as unknown as { _setPhase: (p: string) => void })._setPhase('processing'); + (engine as unknown as { _setPhase: (p: string) => void })._setPhase( + 'processing', + ); engine.pause(); expect(engine.getPhase()).toBe('paused'); engine.resume(); @@ -433,7 +466,9 @@ describe('KokoroEngine.extra — uncovered branches', () => { it('stop from paused returns to ready when the bridge is still mounted', () => { const engine = new KokoroEngine(); engine._setBridge(makeHandle(), 'af_heart'); - (engine as unknown as { _setPhase: (p: string) => void })._setPhase('paused'); + (engine as unknown as { _setPhase: (p: string) => void })._setPhase( + 'paused', + ); engine.stop(); expect(engine.getPhase()).toBe('ready'); diff --git a/__tests__/pro/audio/ttsEngineSubscription.extra.test.ts b/__tests__/pro/audio/ttsEngineSubscription.extra.test.ts index 91cf4fcf3..0144b5ff6 100644 --- a/__tests__/pro/audio/ttsEngineSubscription.extra.test.ts +++ b/__tests__/pro/audio/ttsEngineSubscription.extra.test.ts @@ -64,6 +64,8 @@ beforeEach(() => { playbackDuration: 0, currentAmplitude: 0, overallDownloadProgress: 0, + voiceSwitchProgress: 0, + isSwitchingVoice: false, activeVoiceId: null, playSessionId: 7, error: null, @@ -82,6 +84,35 @@ describe('downloadProgress → store projection', () => { engine.emit('downloadProgress'); expect(state.overallDownloadProgress).toBe(0.42); }); + + it('projects progress into the pending voice switch', () => { + state.isSwitchingVoice = true; + const engine = makeEngine(0.42); + subscribeToEngine(engine as any, deps()); + engine.emit('downloadProgress'); + expect(state.voiceSwitchProgress).toBe(0.42); + }); + + it('measures one aggregate byte rate for every voice download view', () => { + const now = jest.spyOn(Date, 'now') + .mockReturnValueOnce(1_000) + .mockReturnValueOnce(2_000); + const engine = makeEngine(0.5); + subscribeToEngine(engine as any, deps()); + + engine.emit('downloadProgress', { + assetId: 'voice', progress: 0.25, bytesWritten: 250, totalBytes: 1_000, + }); + expect(state.downloadBytesPerSecond).toBeUndefined(); + + engine.emit('downloadProgress', { + assetId: 'voice', progress: 0.5, bytesWritten: 500, totalBytes: 1_000, + }); + expect(state.downloadCurrentBytes).toBe(500); + expect(state.downloadTotalBytes).toBe(1_000); + expect(state.downloadBytesPerSecond).toBe(250); + now.mockRestore(); + }); }); describe('amplitudeChange → store projection', () => { @@ -142,6 +173,15 @@ describe('voiceChanged → store projection', () => { engine.emit('voiceChanged', 'af_heart'); expect(state.activeVoiceId).toBe('af_heart'); }); + + it('does not mark a voice active before its pending switch is ready', () => { + state.isSwitchingVoice = true; + state.activeVoiceId = 'af_heart'; + const engine = makeEngine(); + subscribeToEngine(engine as any, deps()); + engine.emit('voiceChanged', 'bf_emma'); + expect(state.activeVoiceId).toBe('af_heart'); + }); }); describe('phaseChange error branch preserves the existing error', () => { diff --git a/__tests__/pro/audio/ttsStore.extra.test.ts b/__tests__/pro/audio/ttsStore.extra.test.ts index e8c5bbfea..92aeb493c 100644 --- a/__tests__/pro/audio/ttsStore.extra.test.ts +++ b/__tests__/pro/audio/ttsStore.extra.test.ts @@ -103,6 +103,7 @@ const baseSettings = { engineId: 'mock-tts', voiceByEngine: {} as Record, modelDownloaded: {} as Record, + voiceAssetsDownloaded: {} as Record, }; describe('ttsStore — extra branch coverage', () => { @@ -360,6 +361,7 @@ describe('ttsStore — extra branch coverage', () => { expect(mockCurrentEngine.downloadAssets).toHaveBeenCalledTimes(1); expect(getState().settings.modelDownloaded?.['mock-tts']).toBe(true); + expect(getState().settings.voiceAssetsDownloaded?.['mock-tts']).toEqual(['default']); expect(getState().error).toBeNull(); }); @@ -513,10 +515,11 @@ describe('ttsStore persist migration (onRehydrateStorage)', () => { expect(() => opts.onRehydrateStorage()(undefined)).not.toThrow(); }); - it('backfills voiceByEngine and modelDownloaded when missing', () => { + it('backfills voice and download records when missing', () => { const s = runMigration({ engineId: 'kokoro' }); expect(s.voiceByEngine).toEqual({}); expect(s.modelDownloaded).toEqual({}); + expect(s.voiceAssetsDownloaded).toEqual({}); }); it('migrates flat kokoroVoiceId and voiceId into voiceByEngine', () => { diff --git a/__tests__/pro/audio/ui/EngineBridge.test.tsx b/__tests__/pro/audio/ui/EngineBridge.test.tsx index 658ce1879..a45d1ab2c 100644 --- a/__tests__/pro/audio/ui/EngineBridge.test.tsx +++ b/__tests__/pro/audio/ui/EngineBridge.test.tsx @@ -19,6 +19,18 @@ import { render, screen } from '@testing-library/react-native'; jest.mock('react-native-executorch', () => ({ initExecutorch: jest.fn(), + models: { + text_to_speech: { + kokoro: { + en_us: { + heart: () => ({ + voiceSource: 'https://example.test/af_heart.bin', + phonemizerConfig: { lang: 'en-us' }, + }), + }, + }, + }, + }, useTextToSpeech: jest.fn(() => ({ isReady: true, downloadProgress: 1, diff --git a/__tests__/pro/audio/ui/MessageAudioMode.test.tsx b/__tests__/pro/audio/ui/MessageAudioMode.test.tsx index d9022e2a6..ef1e4ba02 100644 --- a/__tests__/pro/audio/ui/MessageAudioMode.test.tsx +++ b/__tests__/pro/audio/ui/MessageAudioMode.test.tsx @@ -20,6 +20,7 @@ import { render, fireEvent } from '@testing-library/react-native'; import { MessageAudioMode } from '@offgrid/pro/audio/ui/MessageAudioMode'; import type { MessageAudioModeProps } from '@offgrid/pro/audio/ui/MessageAudioMode'; import { useTTSStore } from '@offgrid/pro/audio/ttsStore'; +import { useChatStore } from '@offgrid/core/stores'; import type { Message } from '@offgrid/core/types'; import { createUserMessage, @@ -48,10 +49,12 @@ const renderMode = (msg: Message, overrides: Partial = {} render(); const initialTTSState = useTTSStore.getState(); +const initialChatState = useChatStore.getState(); afterEach(() => { jest.clearAllMocks(); useTTSStore.setState(initialTTSState, true); + useChatStore.setState(initialChatState, true); }); describe('MessageAudioMode', () => { @@ -95,6 +98,19 @@ describe('MessageAudioMode', () => { expect(getByText('•••')).toBeTruthy(); }); + it('keeps the newest assistant voice answer transcript open by default', () => { + const conversationId = useChatStore.getState().createConversation('model-1'); + const msg = useChatStore.getState().addMessage(conversationId, { + role: 'assistant', + content: 'The newest answer stays readable.', + }); + + const { getByText } = renderMode(msg); + + expect(getByText('Hide transcript')).toBeTruthy(); + expect(getByText('The newest answer stays readable.')).toBeTruthy(); + }); + it('pressing copy on a completed assistant bubble passes the transcript to onCopy', () => { const onCopy = jest.fn(); const msg = createAssistantMessage('Speak this answer.'); diff --git a/__tests__/pro/audio/ui/TTSSection.test.tsx b/__tests__/pro/audio/ui/TTSSection.test.tsx index 6e1f11c20..79b562cc2 100644 --- a/__tests__/pro/audio/ui/TTSSection.test.tsx +++ b/__tests__/pro/audio/ui/TTSSection.test.tsx @@ -14,7 +14,7 @@ * The store under assertion is NEVER mocked. */ import React from 'react'; -import { render, fireEvent, act } from '@testing-library/react-native'; +import { render, fireEvent, act, waitFor } from '@testing-library/react-native'; // Render each Feather icon as a Text carrying its name, so a test can assert // which glyph shows (check / check-circle / external-link) without reaching into @@ -33,24 +33,25 @@ jest.mock('react-native-vector-icons/Feather', () => { // A minimal fake engine so the store's setVoice action proceeds (it bails when // there is no active engine). setVoice does the real optimistic state update // (activeVoiceId + voiceByEngine) before awaiting this boundary. -jest.mock('../../../../pro/audio/engine', () => { - const engine = { - id: 'kokoro', - displayName: 'Kokoro TTS', - capabilities: { peakRamMB: 82 }, - setVoice: jest.fn(async () => {}), - stop: jest.fn(), - getPhase: () => 'ready', - getRequiredAssets: () => [{ id: 'a', sizeBytes: 82 * 1024 * 1024 }], - isFullyDownloaded: () => true, - initialize: jest.fn(async () => {}), - release: jest.fn(async () => {}), - }; - return { - ttsRegistry: { getActiveEngine: () => engine, getRegisteredIds: () => ['kokoro'] }, - OuteTTSEngine: class {}, - }; -}); +const mockSetVoice = jest.fn(async () => {}); +const mockTTSEngine = { + id: 'kokoro', + displayName: 'Kokoro TTS', + capabilities: { peakRamMB: 82 }, + setVoice: mockSetVoice, + stop: jest.fn(), + getPhase: () => 'ready', + getRequiredAssets: () => [{ id: 'a', sizeBytes: 82 * 1024 * 1024 }], + checkAssetStatus: jest.fn(async () => []), + getOverallDownloadProgress: () => 1, + isFullyDownloaded: () => true, + initialize: jest.fn(async () => {}), + release: jest.fn(async () => {}), +}; +jest.mock('../../../../pro/audio/engine', () => ({ + ttsRegistry: { getActiveEngine: () => mockTTSEngine, getRegisteredIds: () => ['kokoro'] }, + OuteTTSEngine: class {}, +})); // The residency lock/hardware are native boundaries; the mode switch's // initializeEngine side-effect routes through them. Grant room so it proceeds @@ -77,8 +78,8 @@ import { TTSSection } from '@offgrid/pro/audio/ui/TTSSection'; import { useTTSStore } from '@offgrid/pro/audio/ttsStore'; const VOICES = [ - { id: 'af_heart', label: 'Warm', metadata: { accent: 'US', gender: 'Female', persona: 'Friendly' } }, - { id: 'bf_emma', label: 'Gentle', metadata: { accent: '', gender: '', persona: '' } }, + { id: 'af_heart', label: 'Warm', metadata: { accent: 'English (US)', languageCode: 'en-US', gender: 'Female', persona: 'Friendly' } }, + { id: 'bf_emma', label: 'Gentle', metadata: { accent: 'English (UK)', languageCode: 'en-GB', gender: '', persona: '' } }, ] as any; // Snapshot the pristine store so every test starts from the real defaults and @@ -122,6 +123,28 @@ describe('TTSSection', () => { }); }); + describe('when the voice model is downloaded but the engine is cold', () => { + it('shows the voice controls instead of the download empty state', () => { + setStore({ + ...INITIAL, + isReady: false, + voices: VOICES, + activeVoiceId: 'af_heart', + settings: { + ...INITIAL.settings, + interfaceMode: 'chat', + modelDownloaded: { ...INITIAL.settings.modelDownloaded, kokoro: true }, + }, + }); + + const { getByText, getByTestId, queryByText } = render(); + + expect(queryByText(/No voice models downloaded/)).toBeNull(); + expect(getByText('Interface Mode')).toBeTruthy(); + expect(getByTestId('tts-speed-slider')).toBeTruthy(); + }); + }); + // ── Ready branch ───────────────────────────────────────────────────────── describe('when a voice model is ready', () => { beforeEach(() => @@ -141,7 +164,8 @@ describe('TTSSection', () => { expect(getByText('Chat')).toBeTruthy(); expect(getByText('Audio')).toBeTruthy(); expect(getByText('Warm')).toBeTruthy(); - expect(getByText('Gentle')).toBeTruthy(); + expect(queryByText('Gentle')).toBeNull(); + expect(getByTestId('chat-tts-language')).toBeTruthy(); expect(getByTestId('tts-speed-slider')).toBeTruthy(); }); @@ -165,16 +189,89 @@ describe('TTSSection', () => { const { getByText, getByTestId } = render(); // Active voice (af_heart) shows the check glyph; its metadata joins accent + gender. expect(getByTestId('icon-check')).toBeTruthy(); - expect(getByText('US · Female')).toBeTruthy(); + expect(getByText('English (US) · Female')).toBeTruthy(); }); - it('tapping a voice dispatches setVoice → activeVoiceId changes in the REAL store', async () => { - const { getByText } = render(); + it('shows the language download until the requested voice is ready', async () => { + let finishSwitch!: () => void; + const pendingSwitch = new Promise((resolve) => { finishSwitch = resolve; }); + mockSetVoice.mockReturnValueOnce(pendingSwitch); + const realSetVoice = useTTSStore.getState().setVoice; + const setVoiceFromUI = jest.fn(realSetVoice); + setStore({ setVoice: setVoiceFromUI }); + const { getByText, getByTestId, queryByTestId } = render(); expect(useTTSStore.getState().activeVoiceId).toBe('af_heart'); - await act(async () => { fireEvent.press(getByText('Gentle')); }); - // The real setVoice action updates activeVoiceId immediately (optimistic). + fireEvent.press(getByTestId('chat-tts-language')); + await act(async () => { fireEvent.press(getByTestId('chat-tts-language-en-GB')); }); + + expect(setVoiceFromUI).toHaveBeenCalledWith('bf_emma'); + expect(mockSetVoice).toHaveBeenCalledWith('bf_emma'); + expect(mockSetVoice.mock.results[0]?.value).toBe(pendingSwitch); + expect(useTTSStore.getState().activeVoiceId).toBe('af_heart'); + await waitFor(() => expect(useTTSStore.getState().pendingVoiceId).toBe('bf_emma')); + expect(getByTestId('chat-tts-language-download-status')).toBeTruthy(); + expect(getByText(/Downloading English \(UK\) voice/)).toBeTruthy(); + expect(queryByTestId('icon-check-circle')).toBeNull(); + + // A second selection cannot start while one voice is still being prepared. + fireEvent.press(getByTestId('chat-tts-language')); + expect(queryByTestId('chat-tts-language-en-US')).toBeNull(); + + act(() => { useTTSStore.setState({ voiceSwitchProgress: 0.42 }); }); + expect(getByText('Downloading English (UK) voice - 42% · Rate unavailable')).toBeTruthy(); + + act(() => { + useTTSStore.setState({ + downloadCurrentBytes: 21 * 1024 * 1024, + downloadTotalBytes: 50 * 1024 * 1024, + downloadBytesPerSecond: 2 * 1024 * 1024, + }); + }); + expect(getByText('Downloading English (UK) voice - 42% · 21 MB / 50 MB · 2.0 MB/s')).toBeTruthy(); + + await act(async () => { finishSwitch(); await Promise.resolve(); }); + await waitFor(() => expect(getByTestId('chat-tts-language-ready-status')).toBeTruthy()); expect(useTTSStore.getState().activeVoiceId).toBe('bf_emma'); expect(useTTSStore.getState().settings.voiceByEngine[useTTSStore.getState().settings.engineId]).toBe('bf_emma'); + expect(getByText('Gentle')).toBeTruthy(); + }); + + it('prepares a voice from cache when that voice completed before', async () => { + let finishSwitch!: () => void; + mockSetVoice.mockReturnValueOnce(new Promise((resolve) => { finishSwitch = resolve; })); + setStore({ + settings: { + ...useTTSStore.getState().settings, + voiceAssetsDownloaded: { kokoro: ['bf_emma'] }, + }, + }); + const { getByText, getByTestId, queryByText } = render(); + + fireEvent.press(getByTestId('chat-tts-language')); + await act(async () => { fireEvent.press(getByTestId('chat-tts-language-en-GB')); }); + + expect(getByText('Preparing English (UK) voice')).toBeTruthy(); + expect(queryByText(/Downloading English \(UK\) voice/)).toBeNull(); + + await act(async () => { finishSwitch(); await Promise.resolve(); }); + await waitFor(() => expect(getByTestId('chat-tts-language-ready-status')).toBeTruthy()); + }); + + it('shows a failed language download and retries it', async () => { + mockSetVoice + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce(undefined); + const { getByText, getByTestId } = render(); + + fireEvent.press(getByTestId('chat-tts-language')); + await act(async () => { fireEvent.press(getByTestId('chat-tts-language-en-GB')); }); + await waitFor(() => expect(getByTestId('chat-tts-language-download-error')).toBeTruthy()); + expect(getByText('Could not download the English (UK) voice. Check your connection and retry.')).toBeTruthy(); + expect(useTTSStore.getState().activeVoiceId).toBe('af_heart'); + + await act(async () => { fireEvent.press(getByTestId('chat-tts-language-download-retry')); }); + await waitFor(() => expect(useTTSStore.getState().activeVoiceId).toBe('bf_emma')); + expect(mockSetVoice).toHaveBeenCalledTimes(2); }); // ── Mode picker interaction (real store action) ──────────────────────── diff --git a/__tests__/pro/mcp/McpToolExtension.extra.test.ts b/__tests__/pro/mcp/McpToolExtension.extra.test.ts index 69c0f6b3a..450d003be 100644 --- a/__tests__/pro/mcp/McpToolExtension.extra.test.ts +++ b/__tests__/pro/mcp/McpToolExtension.extra.test.ts @@ -17,6 +17,7 @@ // executeMcpTool is the sole boundary (needs a live native MCP client). Keep the rest // of mcpService REAL so getMcpToolsPrompt / parseMcpToolCallsFromText run for real. const mockExecuteMcpTool = jest.fn(); +const mockExecuteCompanionTask = jest.fn(); jest.mock('@offgrid/pro/mcp/mcpService', () => { const actual = jest.requireActual('@offgrid/pro/mcp/mcpService'); return { @@ -25,12 +26,30 @@ jest.mock('@offgrid/pro/mcp/mcpService', () => { executeMcpTool: (...args: unknown[]) => mockExecuteMcpTool(...args), }; }); +jest.mock('@offgrid/pro/mcp/companionTaskMesh', () => ({ + executeCompanionTask: (input: unknown) => mockExecuteCompanionTask(input), +})); + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); import { McpToolExtension } from '@offgrid/pro/mcp/McpToolExtension'; import { useMcpStore } from '@offgrid/pro/mcp/mcpStore'; import { useRemoteServerStore } from '@offgrid/core/stores'; import { SMALL_MODEL_TOOL_BUDGET } from '@offgrid/pro/mcp/schemaTrim'; import type { McpTool } from '@offgrid/pro/mcp/types'; +import { useSyncStore } from '@offgrid/pro/sync/syncStore'; // A compact tool (under the small-model budget) — passes through trimming untouched. const compactTool: McpTool = { @@ -48,7 +67,11 @@ const compactTool: McpTool = { function makeLargeTool(): McpTool { const bigDesc = 'x'.repeat(40); const props: Record = { - required_key: { type: 'string', description: 'must stay', enum: ['a', 'b'] }, + required_key: { + type: 'string', + description: 'must stay', + enum: ['a', 'b'], + }, }; // Many fat optional props so the serialized size blows past the budget. for (let i = 0; i < 12; i++) { @@ -160,14 +183,20 @@ describe('getOpenAISchemas', () => { }); it('falls back to an empty-object schema when a tool has no inputSchema', () => { - const noSchema = { name: 'bare', description: 'no schema' } as unknown as McpTool; + const noSchema = { + name: 'bare', + description: 'no schema', + } as unknown as McpTool; useMcpStore.setState({ serverTools: { s1: [noSchema] }, toolOwners: { bare: 's1' }, enabledTools: ['bare'], }); const schemas = McpToolExtension.getOpenAISchemas!() as any[]; - expect(schemas[0].function.parameters).toEqual({ type: 'object', properties: {} }); + expect(schemas[0].function.parameters).toEqual({ + type: 'object', + properties: {}, + }); }); }); @@ -222,7 +251,9 @@ describe('parseToolCalls / stripFromVisibleText', () => { }); it('leaves text without tags unchanged (trimmed)', () => { - expect(McpToolExtension.stripFromVisibleText(' hello world ')).toBe('hello world'); + expect(McpToolExtension.stripFromVisibleText(' hello world ')).toBe( + 'hello world', + ); }); }); @@ -240,16 +271,139 @@ describe('canHandle', () => { describe('execute', () => { it('returns content + toolCallId on success and never sets error', async () => { - mockExecuteMcpTool.mockResolvedValue({ content: 'the result', durationMs: 42 }); - const r = await McpToolExtension.execute({ id: 'c1', name: 'notion_search', arguments: { query: 'x' } }); - expect(r).toMatchObject({ toolCallId: 'c1', name: 'notion_search', content: 'the result', durationMs: 42 }); + mockExecuteMcpTool.mockResolvedValue({ + content: 'the result', + durationMs: 42, + }); + const r = await McpToolExtension.execute({ + id: 'c1', + name: 'notion_search', + arguments: { query: 'x' }, + }); + expect(r).toMatchObject({ + toolCallId: 'c1', + name: 'notion_search', + content: 'the result', + durationMs: 42, + }); expect(r.error).toBeUndefined(); - expect(mockExecuteMcpTool).toHaveBeenCalledWith('notion_search', { query: 'x' }); + expect(mockExecuteMcpTool).toHaveBeenCalledWith('notion_search', { + query: 'x', + }); + }); + + it('passes the originating chat and device with a companion action', async () => { + useMcpStore.setState({ + servers: [ + { + id: 'desktop-tools', + name: 'Office Mac', + url: 'http://office-mac/mcp', + grantedByDeviceId: 'desktop-1', + }, + { + id: 'studio-tools', + name: 'Studio Mac', + url: 'http://studio-mac/mcp', + grantedByDeviceId: 'desktop-2', + }, + ], + connectionStates: { + 'desktop-tools': 'connected', + 'studio-tools': 'connected', + }, + serverTools: { + 'desktop-tools': [ + { + name: 'web_use', + description: 'Run a web task', + inputSchema: { type: 'object' }, + }, + ], + 'studio-tools': [ + { + name: 'web_use', + description: 'Run a web task', + inputSchema: { type: 'object' }, + }, + ], + }, + enabledTools: ['web_use'], + toolOwners: { web_use: 'desktop-tools' }, + }); + useSyncStore.setState({ + thisDevice: { + id: 'phone-1', + name: 'Ali phone', + platform: 'ios', + version: '1', + host: '', + port: 0, + }, + knownDevices: [ + { + id: 'desktop-1', + name: 'Office Mac', + platform: 'macos', + version: '1', + host: 'office-mac', + port: 1, + status: 'connected', + pairedAt: 1, + lastSeenAt: 1, + }, + { + id: 'desktop-2', + name: 'Studio Alias', + platform: 'macos', + version: '1', + host: 'studio-mac', + port: 1, + status: 'connected', + pairedAt: 1, + lastSeenAt: 1, + }, + ], + connectedDeviceIds: ['desktop-1', 'desktop-2'], + }); + mockExecuteCompanionTask.mockResolvedValue({ + content: 'started', + durationMs: 9, + }); + + await McpToolExtension.execute({ + id: 'task-1', + name: 'web_use', + arguments: { + task: 'Find a flight', + execution_device: 'studio alias', + }, + context: { conversationId: 'chat-mobile-1' }, + }); + + expect(mockExecuteCompanionTask).toHaveBeenCalledWith({ + deviceId: 'desktop-2', + name: 'web_use', + args: { task: 'Find a flight', execution_device: 'studio alias' }, + origin: { + conversationId: 'chat-mobile-1', + launchId: expect.any(String), + deviceId: 'phone-1', + deviceName: 'Ali phone', + executionDeviceId: 'desktop-2', + }, + }); }); it('returns a typed error result (does NOT throw) when the call rejects with an Error', async () => { - mockExecuteMcpTool.mockRejectedValue(new Error('Server "notion" is not connected')); - const r = await McpToolExtension.execute({ id: 'c2', name: 'notion_search', arguments: {} }); + mockExecuteMcpTool.mockRejectedValue( + new Error('Server "notion" is not connected'), + ); + const r = await McpToolExtension.execute({ + id: 'c2', + name: 'notion_search', + arguments: {}, + }); expect(r.toolCallId).toBe('c2'); expect(r.content).toBe(''); expect(r.error).toContain('not connected'); @@ -258,7 +412,11 @@ describe('execute', () => { it('uses the fallback message when the rejection is not an Error instance', async () => { mockExecuteMcpTool.mockRejectedValue('boom-string'); - const r = await McpToolExtension.execute({ id: 'c3', name: 'notion_search', arguments: {} }); + const r = await McpToolExtension.execute({ + id: 'c3', + name: 'notion_search', + arguments: {}, + }); expect(r.error).toBe('MCP tool execution failed'); expect(r.content).toBe(''); }); diff --git a/__tests__/pro/mcp/companionTaskMesh.test.ts b/__tests__/pro/mcp/companionTaskMesh.test.ts new file mode 100644 index 000000000..d02b79bfb --- /dev/null +++ b/__tests__/pro/mcp/companionTaskMesh.test.ts @@ -0,0 +1,100 @@ +jest.mock('../../../pro/sync/syncService', () => ({ + syncService: { + onAppMessage: jest.fn(() => jest.fn()), + sendApp: jest.fn(() => false), + }, +})); + +import { + CompanionTaskMesh, + type CompanionTaskTransport, +} from '../../../pro/mcp/companionTaskMesh'; +import { parseTaskCapability } from '../../../pro/mcp/companionTaskMeshLogic'; + +const webUse = { + name: 'web_use', + description: 'Use the Desktop browser.', + inputSchema: { + type: 'object', + properties: { goal: { type: 'string' } }, + required: ['goal'], + }, +}; + +class MeshBoundary implements CompanionTaskTransport { + handler: + | ((deviceId: string, channel: string, data: unknown) => void) + | undefined; + sent: { deviceId: string; channel: string; data: unknown }[] = []; + connected = true; + + onAppMessage( + handler: (deviceId: string, channel: string, data: unknown) => void, + ): () => void { + this.handler = handler; + return () => { + this.handler = undefined; + }; + } + + sendApp(deviceId: string, channel: string, data: unknown): boolean { + this.sent.push({ deviceId, channel, data }); + return this.connected; + } +} + +const origin = { + conversationId: 'chat-1', + launchId: 'launch-1', + deviceId: 'phone-1', + deviceName: 'Phone', + executionDeviceId: 'mac-1', +}; + +describe('companion task mesh', () => { + afterEach(() => jest.useRealTimers()); + + it('accepts the two mesh task tools from the execution device', () => { + const capability = parseTaskCapability({ + version: 1, + executionDevice: { id: 'mac-1', name: 'My Mac' }, + remoteTasksAllowed: true, + tools: [webUse, { ...webUse, name: 'computer_use' }], + }); + expect(capability?.tools.map(tool => tool.name)).toEqual([ + 'web_use', + 'computer_use', + ]); + }); + + it('drops foreign tools and rejects malformed capability messages', () => { + expect( + parseTaskCapability({ + version: 1, + executionDevice: { id: 'mac-1', name: 'My Mac' }, + remoteTasksAllowed: true, + tools: [{ ...webUse, name: 'messages_send' }], + })?.tools, + ).toEqual([]); + expect(parseTaskCapability({ version: 1 })).toBeNull(); + }); + + it('clears and rejects pending work when the runtime deactivates', async () => { + jest.useFakeTimers(); + const boundary = new MeshBoundary(); + const mesh = new CompanionTaskMesh(boundary, () => 'request-1', 30_000); + const deactivate = mesh.start(); + const result = mesh.execute({ + deviceId: 'mac-1', + name: 'web_use', + args: { task: 'Find a flight' }, + origin, + }); + + deactivate(); + + await expect(result).rejects.toThrow('stopped before the Desktop replied'); + expect(boundary.handler).toBeUndefined(); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/__tests__/pro/mcp/mcpClient.test.ts b/__tests__/pro/mcp/mcpClient.test.ts index 51517a152..1b3669e65 100644 --- a/__tests__/pro/mcp/mcpClient.test.ts +++ b/__tests__/pro/mcp/mcpClient.test.ts @@ -90,7 +90,11 @@ class FakeXhr { } } -function queueJson(status: number, body: unknown, headers: Record = {}) { +function queueJson( + status: number, + body: unknown, + headers: Record = {}, +) { responseQueue.push({ kind: 'load', status, @@ -135,7 +139,10 @@ describe('McpClient.initialize', () => { const first = JSON.parse(recorded[0].body); expect(first.method).toBe('initialize'); expect(first.params.protocolVersion).toBe('2024-11-05'); - expect(first.params.clientInfo).toEqual({ name: 'offgrid', version: '1.0' }); + expect(first.params.clientInfo).toEqual({ + name: 'offgrid', + version: '1.0', + }); const second = JSON.parse(recorded[1].body); expect(second.method).toBe('notifications/initialized'); // ids are monotonically increasing per client instance @@ -202,6 +209,39 @@ describe('McpClient.callTool', () => { expect(sent.params).toEqual({ name: 'echo', arguments: { q: 1 } }); }); + it('sends namespaced task origin metadata with a companion tool call', async () => { + queueJson(200, { + jsonrpc: '2.0', + id: 1, + result: { content: [{ type: 'text', text: 'started' }] }, + }); + + await makeClient().callTool( + 'web_use', + { task: 'Find a flight' }, + { + 'ai.offgrid/taskOrigin': { + conversationId: 'chat-mobile-1', + deviceId: 'phone-1', + deviceName: 'Ali phone', + }, + }, + ); + + const sent = JSON.parse(recorded[0].body); + expect(sent.params).toEqual({ + name: 'web_use', + arguments: { task: 'Find a flight' }, + _meta: { + 'ai.offgrid/taskOrigin': { + conversationId: 'chat-mobile-1', + deviceId: 'phone-1', + deviceName: 'Ali phone', + }, + }, + }); + }); + it('appends a note for non-text blocks alongside text', async () => { queueJson(200, { jsonrpc: '2.0', @@ -216,7 +256,9 @@ describe('McpClient.callTool', () => { }); const out = await makeClient().callTool('t', {}); - expect(out).toBe('caption\n[2 non-text result(s) not shown: image, resource]'); + expect(out).toBe( + 'caption\n[2 non-text result(s) not shown: image, resource]', + ); }); it('returns only the note when there is no text block', async () => { @@ -252,7 +294,10 @@ describe('McpClient.callTool', () => { queueJson(200, { jsonrpc: '2.0', id: 1, - result: { isError: true, content: [{ type: 'text', text: 'rate limited' }] }, + result: { + isError: true, + content: [{ type: 'text', text: 'rate limited' }], + }, }); await expect(makeClient().callTool('t', {})).rejects.toThrow( /reported an error: rate limited/, @@ -260,7 +305,11 @@ describe('McpClient.callTool', () => { }); it('throws with "no detail" when isError is set but no text present', async () => { - queueJson(200, { jsonrpc: '2.0', id: 1, result: { isError: true, content: [] } }); + queueJson(200, { + jsonrpc: '2.0', + id: 1, + result: { isError: true, content: [] }, + }); await expect(makeClient().callTool('t', {})).rejects.toThrow( /reported an error: no detail/, ); @@ -288,7 +337,9 @@ describe('rpc error mapping', () => { it('throws HTTP for a >=400 non-401 response', async () => { queueJson(503, { jsonrpc: '2.0' }); - await expect(makeClient().listTools()).rejects.toThrow('MCP tools/list: HTTP 503'); + await expect(makeClient().listTools()).rejects.toThrow( + 'MCP tools/list: HTTP 503', + ); }); it('rejects with a network error when xhr.onerror fires', async () => { @@ -348,7 +399,9 @@ describe('401 / auth retry', () => { it('throws 401 immediately when there is no onUnauthorized handler', async () => { queueJson(401, {}); - await expect(makeClient().listTools()).rejects.toThrow('unauthorized (401)'); + await expect(makeClient().listTools()).rejects.toThrow( + 'unauthorized (401)', + ); expect(recorded).toHaveLength(1); }); }); @@ -398,7 +451,10 @@ describe('auth headers', () => { name: 'Authorization', value: `Bearer token-${++call}`, })); - await makeClient({ getAuthHeader, onUnauthorized: async () => true }).listTools(); + await makeClient({ + getAuthHeader, + onUnauthorized: async () => true, + }).listTools(); expect(recorded[0].requestHeaders.Authorization).toBe('Bearer token-1'); expect(recorded[1].requestHeaders.Authorization).toBe('Bearer token-2'); @@ -407,7 +463,11 @@ describe('auth headers', () => { describe('session id + transport headers', () => { it('captures mcp-session-id from a response and sends it on subsequent requests', async () => { - queueJson(200, { jsonrpc: '2.0', id: 1, result: {} }, { 'mcp-session-id': 'sess-42' }); + queueJson( + 200, + { jsonrpc: '2.0', id: 1, result: {} }, + { 'mcp-session-id': 'sess-42' }, + ); queueJson(200, { jsonrpc: '2.0', id: 2, result: {} }); const client = makeClient(); @@ -428,7 +488,9 @@ describe('session id + transport headers', () => { describe('SSE (text/event-stream) parsing', () => { it('parses the first JSON data line out of an SSE body', async () => { - const tools = [{ name: 't', description: 'd', inputSchema: { type: 'object' } }]; + const tools = [ + { name: 't', description: 'd', inputSchema: { type: 'object' } }, + ]; const sse = `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: 1, @@ -447,7 +509,11 @@ describe('SSE (text/event-stream) parsing', () => { it('skips [DONE] and blank data lines, returning the real payload', async () => { const sse = `data: [DONE]\n\n` + - `data: ${JSON.stringify({ jsonrpc: '2.0', id: 1, result: { tools: [] } })}\n\n`; + `data: ${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { tools: [] }, + })}\n\n`; responseQueue.push({ kind: 'load', status: 200, @@ -485,14 +551,22 @@ describe('malformed JSON body', () => { describe('integration: full initialize -> listTools -> callTool session', () => { it('carries the session id across all three calls and returns tool output', async () => { // initialize - queueJson(200, { jsonrpc: '2.0', id: 1, result: {} }, { 'mcp-session-id': 'S1' }); + queueJson( + 200, + { jsonrpc: '2.0', id: 1, result: {} }, + { 'mcp-session-id': 'S1' }, + ); // notifications/initialized queueJson(200, { jsonrpc: '2.0', id: 2, result: {} }); // tools/list queueJson(200, { jsonrpc: '2.0', id: 3, - result: { tools: [{ name: 'greet', description: 'x', inputSchema: { type: 'object' } }] }, + result: { + tools: [ + { name: 'greet', description: 'x', inputSchema: { type: 'object' } }, + ], + }, }); // tools/call queueJson(200, { diff --git a/__tests__/pro/mcp/mcpService.test.ts b/__tests__/pro/mcp/mcpService.test.ts index bca7cdc9b..798ddfcea 100644 --- a/__tests__/pro/mcp/mcpService.test.ts +++ b/__tests__/pro/mcp/mcpService.test.ts @@ -19,9 +19,14 @@ jest.mock('@offgrid/core/utils/logger', () => ({ // `mock`-prefixed so the hoisted jest.mock factory may reference them. The fake reads its // per-test behavior from these module-level knobs (a URL-keyed behavior map + a default). const mockClientCtor = jest.fn(); +const mockClientClose = jest.fn(); const mockClientBehaviorByUrl: Record< string, - { initialize?: () => Promise; listTools?: () => Promise; callTool?: () => Promise } + { + initialize?: () => Promise; + listTools?: () => Promise; + callTool?: () => Promise; + } > = {}; jest.mock('../../../pro/mcp/mcpClient', () => { class FakeMcpClient { @@ -42,6 +47,9 @@ jest.mock('../../../pro/mcp/mcpClient', () => { const b = mockClientBehaviorByUrl[this.config.url]; return b?.callTool ? b.callTool() : Promise.resolve(''); } + close() { + mockClientClose(this.config.url); + } } return { McpClient: FakeMcpClient }; }); @@ -85,7 +93,11 @@ import type { McpServerConfig, McpTool } from '@offgrid/pro/mcp/types'; // module) — required so the service's `err instanceof NeedsAuthorizationError` matches. const { NeedsAuthorizationError } = require('../../../pro/mcp/oauth'); -const tool = (name: string, description = 'a tool', extra: Partial = {}): McpTool => ({ +const tool = ( + name: string, + description = 'a tool', + extra: Partial = {}, +): McpTool => ({ name, description, inputSchema: { type: 'object' }, @@ -107,13 +119,15 @@ function resetStores() { beforeEach(() => { jest.clearAllMocks(); - for (const k of Object.keys(mockClientBehaviorByUrl)) delete mockClientBehaviorByUrl[k]; + for (const k of Object.keys(mockClientBehaviorByUrl)) + delete mockClientBehaviorByUrl[k]; resetStores(); }); describe('parseMcpToolCallsFromText', () => { it('extracts a single well-formed call and strips its tag from the text', () => { - const text = 'before {"name":"search","arguments":{"q":"x"}} after'; + const text = + 'before {"name":"search","arguments":{"q":"x"}} after'; const { calls, cleanedText } = parseMcpToolCallsFromText(text); expect(calls).toHaveLength(1); expect(calls[0].name).toBe('search'); @@ -176,7 +190,9 @@ describe('getMcpToolsPrompt', () => { toolOwners: { search: 'srv1' }, serverTools: { srv1: [tool('search', 'find things')] }, }); - useRemoteServerStore.setState({ activeRemoteTextModelId: 'remote-x' } as any); + useRemoteServerStore.setState({ + activeRemoteTextModelId: 'remote-x', + } as any); const prompt = getMcpToolsPrompt(['search']); expect(prompt).toContain('mcp_tool_call'); expect(prompt).toContain('- search: find things'); @@ -200,7 +216,9 @@ describe('getMcpToolsPrompt', () => { toolOwners: { search: 'srv1' }, serverTools: { srv1: [tool('search', long)] }, }); - useRemoteServerStore.setState({ activeRemoteTextModelId: 'remote-x' } as any); + useRemoteServerStore.setState({ + activeRemoteTextModelId: 'remote-x', + } as any); const prompt = getMcpToolsPrompt(['search']); expect(prompt).toContain('y'.repeat(400)); }); @@ -210,7 +228,9 @@ describe('getMcpToolsPrompt', () => { toolOwners: { owned: 'srv1' }, serverTools: { srv1: [tool('owned')] }, }); - useRemoteServerStore.setState({ activeRemoteTextModelId: 'remote-x' } as any); + useRemoteServerStore.setState({ + activeRemoteTextModelId: 'remote-x', + } as any); const prompt = getMcpToolsPrompt(['owned', 'orphan']); expect(prompt).toContain('- owned:'); expect(prompt).not.toContain('orphan'); @@ -230,7 +250,9 @@ describe('getServerToolCount', () => { describe('connectServer', () => { it('throws when the server id is unknown (real store lookup)', async () => { - await expect(connectServer('nope')).rejects.toThrow('Server nope not found'); + await expect(connectServer('nope')).rejects.toThrow( + 'Server nope not found', + ); }); it('connects a no-auth server: real store gets connected state + tools + owners', async () => { @@ -238,7 +260,9 @@ describe('connectServer', () => { useMcpStore.setState({ servers: [srv] }); // Arm the client at this URL to return one tool. const tools = [tool('search')]; - mockClientBehaviorByUrl['https://s1'] = { listTools: () => Promise.resolve(tools) }; + mockClientBehaviorByUrl['https://s1'] = { + listTools: () => Promise.resolve(tools), + }; await connectServer('srv1'); @@ -280,14 +304,43 @@ describe('connectServer', () => { expect(useMcpStore.getState().connectionStates.bad).toBe('error'); // Failed connect must NOT register a live client, so its tool can't be executed. useMcpStore.setState({ toolOwners: { badtool: 'bad' } }); - await expect(executeMcpTool('badtool', {})).rejects.toThrow('is not connected'); + await expect(executeMcpTool('badtool', {})).rejects.toThrow( + 'is not connected', + ); + }); + + it('discards a stale connection after the route is replaced', async () => { + const srv: McpServerConfig = { id: 'moving', name: 'Mac', url: 'http://old/mcp' }; + useMcpStore.setState({ servers: [srv] }); + let finishList!: (tools: McpTool[]) => void; + mockClientBehaviorByUrl['http://old/mcp'] = { + listTools: () => new Promise(resolve => { finishList = resolve; }), + }; + + const pending = connectServer('moving'); + await new Promise(resolve => setImmediate(resolve)); + disconnectServer('moving'); + finishList([tool('computer_use')]); + await pending; + + expect(mockClientClose).toHaveBeenCalledWith('http://old/mcp'); + expect(useMcpStore.getState().connectionStates.moving).toBeUndefined(); + expect(useMcpStore.getState().serverTools.moving).toBeUndefined(); }); describe('oauth flow', () => { - const oauthMeta = { authorizationEndpoint: 'https://o/auth', tokenEndpoint: 'https://o/tok' } as any; + const oauthMeta = { + authorizationEndpoint: 'https://o/auth', + tokenEndpoint: 'https://o/tok', + } as any; it('runs interactive authorize when there is no cached metadata, stores it, and connects', async () => { - const srv: McpServerConfig = { id: 'o1', name: 'O', url: 'https://o', authMode: 'oauth' }; + const srv: McpServerConfig = { + id: 'o1', + name: 'O', + url: 'https://o', + authMode: 'oauth', + }; useMcpStore.setState({ servers: [srv] }); mockAuthorizeServer.mockResolvedValue(oauthMeta); mockEnsureAccessToken.mockResolvedValue('tok-123'); @@ -295,7 +348,9 @@ describe('connectServer', () => { await connectServer('o1', true); const st = useMcpStore.getState(); - expect(mockAuthorizeServer).toHaveBeenCalledWith('o1', 'https://o', { manualClient: undefined }); + expect(mockAuthorizeServer).toHaveBeenCalledWith('o1', 'https://o', { + manualClient: undefined, + }); // Metadata got persisted onto the server config by the real store. expect(st.servers[0].oauth).toEqual(oauthMeta); expect(st.connectionStates.o1).toBe('connected'); @@ -308,10 +363,17 @@ describe('connectServer', () => { }); it('does NOT pop a browser on a silent connect with no cached metadata (throws NeedsAuthorization -> error)', async () => { - const srv: McpServerConfig = { id: 'o2', name: 'O', url: 'https://o', authMode: 'oauth' }; + const srv: McpServerConfig = { + id: 'o2', + name: 'O', + url: 'https://o', + authMode: 'oauth', + }; useMcpStore.setState({ servers: [srv] }); - await expect(connectServer('o2', false)).rejects.toBeInstanceOf(NeedsAuthorizationError); + await expect(connectServer('o2', false)).rejects.toBeInstanceOf( + NeedsAuthorizationError, + ); expect(mockAuthorizeServer).not.toHaveBeenCalled(); expect(useMcpStore.getState().connectionStates.o2).toBe('error'); }); @@ -335,7 +397,10 @@ describe('connectServer', () => { expect(mockAuthorizeServer).toHaveBeenCalledTimes(1); expect(useMcpStore.getState().connectionStates.o3).toBe('connected'); - expect(useMcpStore.getState().servers[0].oauth).toEqual({ ...oauthMeta, reAuthed: true }); + expect(useMcpStore.getState().servers[0].oauth).toEqual({ + ...oauthMeta, + reAuthed: true, + }); }); it('does NOT re-consent on a silent connect when the cached token is dead (rethrows)', async () => { @@ -347,9 +412,13 @@ describe('connectServer', () => { oauth: oauthMeta, }; useMcpStore.setState({ servers: [srv] }); - mockEnsureAccessToken.mockRejectedValue(new NeedsAuthorizationError('o4')); + mockEnsureAccessToken.mockRejectedValue( + new NeedsAuthorizationError('o4'), + ); - await expect(connectServer('o4', false)).rejects.toBeInstanceOf(NeedsAuthorizationError); + await expect(connectServer('o4', false)).rejects.toBeInstanceOf( + NeedsAuthorizationError, + ); expect(mockAuthorizeServer).not.toHaveBeenCalled(); expect(useMcpStore.getState().connectionStates.o4).toBe('error'); }); @@ -437,7 +506,9 @@ describe('reconnectSavedServers', () => { describe('executeMcpTool', () => { it('throws when no server owns the tool', async () => { - await expect(executeMcpTool('unknown', {})).rejects.toThrow('No server owns tool "unknown"'); + await expect(executeMcpTool('unknown', {})).rejects.toThrow( + 'No server owns tool "unknown"', + ); }); it('throws when the owning server has no live client', async () => { @@ -457,7 +528,16 @@ describe('executeMcpTool', () => { _registerClientDirect('srvL', fake as any); useMcpStore.setState({ toolOwners: { search: 'srvL' } }); - const res = await executeMcpTool('search', { q: 'hi' }); + const res = await executeMcpTool( + 'search', + { q: 'hi' }, + { + launchId: 'launch-private-chat', + conversationId: 'private-chat', + deviceId: 'phone-1', + }, + ); + // An arbitrary MCP server must not receive private chat or device identity. expect(fake.callTool).toHaveBeenCalledWith('search', { q: 'hi' }); expect(res.content).toBe('tool output'); expect(typeof res.durationMs).toBe('number'); @@ -466,6 +546,51 @@ describe('executeMcpTool', () => { disconnectServer('srvL'); // clean up the live client }); + it('binds a paired Desktop action to the originating Mobile chat', async () => { + const fake = { + initialize: jest.fn(), + listTools: jest.fn(), + callTool: jest.fn().mockResolvedValue('task started'), + }; + _registerClientDirect('desktop-tools', fake as any); + useMcpStore.setState({ + servers: [ + { + id: 'desktop-tools', + name: 'Office Mac', + url: 'http://office-mac/mcp', + grantedByDeviceId: 'desktop-1', + }, + ], + toolOwners: { web_use: 'desktop-tools' }, + }); + + await executeMcpTool( + 'web_use', + { task: 'Find a flight' }, + { + launchId: 'launch-chat-mobile-1', + conversationId: 'chat-mobile-1', + deviceId: 'phone-1', + deviceName: 'Ali phone', + }, + ); + + expect(fake.callTool).toHaveBeenCalledWith( + 'web_use', + { task: 'Find a flight' }, + { + 'ai.offgrid/taskOrigin': { + launchId: 'launch-chat-mobile-1', + conversationId: 'chat-mobile-1', + deviceId: 'phone-1', + deviceName: 'Ali phone', + }, + }, + ); + disconnectServer('desktop-tools'); + }); + it('propagates an error thrown by the client callTool', async () => { const fake = { initialize: jest.fn(), @@ -481,7 +606,11 @@ describe('executeMcpTool', () => { describe('disconnectServer', () => { it('drops the live client and clears connection + tool data in the real store', async () => { - const fake = { initialize: jest.fn(), listTools: jest.fn(), callTool: jest.fn() }; + const fake = { + initialize: jest.fn(), + listTools: jest.fn(), + callTool: jest.fn(), + }; _registerClientDirect('srvD', fake as any); useMcpStore.setState({ toolOwners: { t: 'srvD' }, @@ -503,10 +632,22 @@ describe('disconnectServer', () => { describe('signOutServer', () => { it('revokes tokens, drops the client, and clears cached oauth metadata', async () => { - const fake = { initialize: jest.fn(), listTools: jest.fn(), callTool: jest.fn() }; + const fake = { + initialize: jest.fn(), + listTools: jest.fn(), + callTool: jest.fn(), + }; _registerClientDirect('srvS', fake as any); useMcpStore.setState({ - servers: [{ id: 'srvS', name: 'S', url: 'https://s', authMode: 'oauth', oauth: { a: 1 } as any }], + servers: [ + { + id: 'srvS', + name: 'S', + url: 'https://s', + authMode: 'oauth', + oauth: { a: 1 } as any, + }, + ], toolOwners: { t: 'srvS' }, }); mockRevokeLocalTokens.mockResolvedValue(undefined); @@ -519,10 +660,22 @@ describe('signOutServer', () => { }); it('still clears metadata + client when token revoke rejects (swallowed catch)', async () => { - const fake = { initialize: jest.fn(), listTools: jest.fn(), callTool: jest.fn() }; + const fake = { + initialize: jest.fn(), + listTools: jest.fn(), + callTool: jest.fn(), + }; _registerClientDirect('srvS2', fake as any); useMcpStore.setState({ - servers: [{ id: 'srvS2', name: 'S', url: 'https://s', authMode: 'oauth', oauth: { a: 1 } as any }], + servers: [ + { + id: 'srvS2', + name: 'S', + url: 'https://s', + authMode: 'oauth', + oauth: { a: 1 } as any, + }, + ], }); mockRevokeLocalTokens.mockRejectedValue(new Error('revoke failed')); diff --git a/__tests__/pro/runtimeDeactivation.integration.test.ts b/__tests__/pro/runtimeDeactivation.integration.test.ts new file mode 100644 index 000000000..47212a954 --- /dev/null +++ b/__tests__/pro/runtimeDeactivation.integration.test.ts @@ -0,0 +1,239 @@ +const mockClipboardEntitlement = jest.fn(); +const mockEmailCalendarEntitlement = jest.fn(); +const mockDisconnectServer = jest.fn(); +const mockAudioCleanup = jest.fn(); +const mockGrantCleanup = jest.fn(); +const mockGrantRefreshCleanup = jest.fn(); +const mockCompanionTaskCleanup = jest.fn(); +const mockReconcileCleanup = jest.fn(); +let mockLicenseInfoListener: ((info: { isPro: boolean }) => void) | undefined; + +jest.mock('@offgrid/core/bootstrap/slotRegistry', () => ({ + SLOTS: { + appRoot: 'app.root', + homeSyncCard: 'home.syncCard', + homeNotificationsButton: 'home.notificationsButton', + taskToolDetail: 'message.taskToolDetail', + autoSetupVoiceIndicator: 'autoSetup.voiceIndicator', + }, +})); +jest.mock('@offgrid/core/bootstrap/hookRegistry', () => ({ + HOOKS: { + onboardingAdditionalSlides: 'onboarding.additionalSlides', + clipboardRecordLocalText: 'clipboard.recordLocalText', + }, +})); +jest.mock('@offgrid/core/utils/logger', () => ({ + __esModule: true, + default: { log: jest.fn(), warn: jest.fn() }, +})); + +jest.mock('../../pro/mcp/McpToolExtension', () => ({ + McpToolExtension: { id: 'mcp' }, +})); +jest.mock('../../pro/tools/EmailCalendarExtension', () => ({ + EmailCalendarExtension: { id: 'email-calendar' }, + setEmailCalendarEntitlementActive: mockEmailCalendarEntitlement, +})); +jest.mock('../../pro/audio', () => ({ + activateAudio: (options: { + registerScreen: (screen: { + name: string; + component: () => null; + }) => () => void; + registerSlot: (name: string, component: () => null) => () => void; + registerHook: (name: string, hook: () => void) => () => void; + }) => { + const disposeScreen = options.registerScreen({ + name: 'AudioSettings', + component: () => null, + }); + const disposeSlot = options.registerSlot('audio.slot', () => null); + const disposeHook = options.registerHook('audio.hook', () => undefined); + return () => { + disposeHook(); + disposeSlot(); + disposeScreen(); + mockAudioCleanup(); + }; + }, +})); + +for (const modulePath of [ + '../../pro/ui/AutoSetupVoiceIndicator', + '../../pro/ui/McpServersScreen', + '../../pro/ui/McpToolsScreen', + '../../pro/ui/McpGuideScreen', + '../../pro/ui/SyncScreen', + '../../pro/ui/SyncScreen/SyncSharingSettingsScreen', + '../../pro/ui/SyncScreen/SyncActivityScreen', + '../../pro/ui/SyncScreen/SyncFilesScreen', + '../../pro/ui/ClipboardScreen', + '../../pro/ui/SyncHomeCard', + '../../pro/ui/HomeNotificationsButton', + '../../pro/ui/SyncNotificationsScreen', + '../../pro/ui/ProRoot', +]) { + jest.mock(modulePath, () => new Proxy({}, { get: () => () => null })); +} + +jest.mock('../../pro/mcp/mcpStore', () => ({ + useMcpStore: { + getState: () => ({ servers: [{ id: 'server-1' }] }), + persist: { hasHydrated: () => true, onFinishHydration: jest.fn() }, + }, +})); +jest.mock('../../pro/mcp/mcpService', () => ({ + disconnectServer: mockDisconnectServer, + reconnectSavedServers: jest.fn(async () => undefined), +})); +jest.mock('../../pro/mcp/mcpToolGrantService', () => ({ + initConnectedToolGrantRefresh: () => mockGrantRefreshCleanup, + initMcpToolGrants: () => mockGrantCleanup, + initToolGrantReconcile: () => mockReconcileCleanup, +})); +jest.mock('../../pro/mcp/companionTaskMesh', () => ({ + initCompanionTaskMesh: () => mockCompanionTaskCleanup, +})); + +jest.mock('../../pro/sync/syncService', () => ({ + syncService: { + prepareActivation: jest.fn(async () => undefined), + start: jest.fn(async () => undefined), + onAppMessage: jest.fn(() => jest.fn()), + onDisconnected: jest.fn(() => jest.fn()), + connectedDeviceIds: jest.fn(() => []), + thisDeviceId: jest.fn(() => undefined), + sendApp: jest.fn(() => false), + }, +})); +jest.mock('../../pro/sync/modelTransferService', () => ({ + modelTransferService: { start: jest.fn() }, +})); +jest.mock('../../pro/sync/stateSyncService', () => ({ + stateSyncService: { + start: jest.fn(async () => undefined), + recordMutation: jest.fn(), + stageMutation: jest.fn(), + sendSharedFileRecord: jest.fn(), + }, +})); +jest.mock('../../pro/sync/clipboardSyncService', () => ({ + clipboardSyncService: { + start: jest.fn(async () => undefined), + setEntitlementActive: mockClipboardEntitlement, + recordLocalText: jest.fn(async () => undefined), + }, +})); +jest.mock('../../pro/sync/chatStreamService', () => ({ + chatStreamService: { + start: jest.fn(async () => undefined), + discardConversation: jest.fn(), + }, +})); +jest.mock('../../pro/sync/knowledgeDocumentSyncService', () => ({ + knowledgeDocumentSyncService: { + start: jest.fn(), + handleLocalMutation: jest.fn(async () => undefined), + }, +})); +jest.mock('../../pro/sync/sharedFileSyncService', () => ({ + sharedFileSyncService: { start: jest.fn(async () => undefined) }, +})); +jest.mock('../../pro/sync/fileTransferService', () => ({ + fileTransferService: { loadHistory: jest.fn(async () => undefined) }, +})); +jest.mock('../../pro/sync/fileCompletionNotificationService', () => ({ + fileCompletionNotificationService: { start: jest.fn(async () => undefined) }, +})); +jest.mock('../../pro/sync/entitlementActivation', () => ({ + setEntitlementImportedHandler: jest.fn(), +})); +jest.mock('../../pro/licensing/proLicenseProvider', () => ({ + proLicenseProvider: {}, + onProLicenseInfoChanged: jest.fn( + (listener: (info: { isPro: boolean }) => void) => { + mockLicenseInfoListener = listener; + return jest.fn(); + }, + ), +})); + +describe('the paid mobile runtime after live entitlement loss', () => { + it('removes paid surfaces and stops paid work in the same process', async () => { + const disposeByScreen = new Map(); + const disposeBySlot = new Map(); + const disposeByHook = new Map(); + const toolDisposers: jest.Mock[] = []; + const options = { + registerToolExtension: jest.fn(() => { + const dispose = jest.fn(); + toolDisposers.push(dispose); + return dispose; + }), + registerScreen: jest.fn((screen: { name: string }) => { + const dispose = jest.fn(); + disposeByScreen.set(screen.name, dispose); + return dispose; + }), + registerSettingsSection: jest.fn(() => jest.fn()), + registerSlot: jest.fn((name: string) => { + const dispose = jest.fn(); + disposeBySlot.set(name, dispose); + return dispose; + }), + registerHook: jest.fn((name: string) => { + const dispose = jest.fn(); + disposeByHook.set(name, dispose); + return dispose; + }), + }; + const pro = require('../../pro') as typeof import('../../pro'); + + pro.configureProEntitlementProvider(jest.fn()); + expect(options.registerSlot).not.toHaveBeenCalledWith( + 'autoSetup.voiceIndicator', + expect.anything(), + ); + pro.activate(options as Parameters[0]); + expect(options.registerSlot).toHaveBeenCalledWith( + 'message.taskToolDetail', + expect.any(Function), + ); + expect(options.registerSlot).toHaveBeenCalledWith( + 'autoSetup.voiceIndicator', + expect.any(Function), + ); + expect(mockClipboardEntitlement).toHaveBeenLastCalledWith(true); + expect(mockEmailCalendarEntitlement).toHaveBeenLastCalledWith(true); + + mockLicenseInfoListener?.({ isPro: false }); + for (let index = 0; index < 20; index += 1) await Promise.resolve(); + + expect(mockClipboardEntitlement).toHaveBeenLastCalledWith(false); + expect(mockEmailCalendarEntitlement).toHaveBeenLastCalledWith(false); + expect(toolDisposers).toHaveLength(2); + expect( + toolDisposers.every(dispose => dispose.mock.calls.length === 1), + ).toBe(true); + expect(disposeByScreen.get('McpServers')).toHaveBeenCalledTimes(1); + expect(disposeByScreen.get('McpTools')).toHaveBeenCalledTimes(1); + expect(disposeByScreen.get('McpGuide')).toHaveBeenCalledTimes(1); + expect(disposeByScreen.get('AudioSettings')).toHaveBeenCalledTimes(1); + expect(disposeBySlot.get('app.root')).toHaveBeenCalledTimes(1); + expect(disposeBySlot.get('autoSetup.voiceIndicator')).toHaveBeenCalledTimes( + 1, + ); + expect(disposeBySlot.get('audio.slot')).toHaveBeenCalledTimes(1); + expect(disposeByHook.get('audio.hook')).toHaveBeenCalledTimes(1); + expect(mockAudioCleanup).toHaveBeenCalledTimes(1); + expect(mockGrantCleanup).toHaveBeenCalledTimes(1); + expect(mockGrantRefreshCleanup).toHaveBeenCalledTimes(1); + expect(mockCompanionTaskCleanup).toHaveBeenCalledTimes(1); + expect(mockReconcileCleanup).toHaveBeenCalledTimes(1); + expect(mockDisconnectServer).toHaveBeenCalledWith('server-1'); + + // Entitlement-recovery Sync stays registered so the user can reactivate. + expect(disposeByScreen.get('Sync')).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/pro/sync/KnownDevicesSection.integration.test.tsx b/__tests__/pro/sync/KnownDevicesSection.integration.test.tsx new file mode 100644 index 000000000..808961b30 --- /dev/null +++ b/__tests__/pro/sync/KnownDevicesSection.integration.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react-native'; +import type { SyncControlAction, SyncManagedDevice } from '@offgrid/sync'; +import { KnownDevicesSection } from '../../../pro/ui/SyncScreen/KnownDevicesSection'; + +const enabled: SyncControlAction = { visible: true, enabled: true }; +const hidden: SyncControlAction = { visible: false, enabled: false }; + +function savedDevice(id: string, name: string): SyncManagedDevice { + return { + id, + name, + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 37878, + saved: true, + state: 'offline', + onNetwork: false, + route: { kind: 'unknown', label: 'Unknown' }, + availableRoutes: [], + actions: { + pair: hidden, + pairAgain: hidden, + reconnect: enabled, + disconnect: hidden, + rename: hidden, + evict: enabled, + retryEviction: hidden, + dismissEviction: hidden, + sendModel: hidden, + }, + evictionConfirmation: { + title: `Forget ${name}?`, + description: 'This removes the saved device.', + confirmLabel: 'Forget', + }, + }; +} + +describe(' action ownership', () => { + it('shows reconnect progress only on the device being reconnected', async () => { + let finishReconnect!: () => void; + const onReconnect = jest.fn( + () => + new Promise(resolve => { + finishReconnect = resolve; + }), + ); + const devices = [ + savedDevice('mac-debug', 'OGAD: Mac (Debug)'), + savedDevice('iphone-debug', 'iPhone (Debug)'), + ]; + const ui = render( + 'reconnected')} + onDisconnect={jest.fn(() => true)} + onReconnect={onReconnect} + onSetManualEndpoint={jest.fn()} + manualEndpointDeviceIds={[]} + onSendModel={jest.fn()} + onForget={jest.fn(async () => undefined)} + reachabilityErrors={{}} + />, + ); + + fireEvent.press(ui.getByTestId('sync-reconnect-mac-debug')); + + expect( + await ui.findByTestId('sync-reconnect-loader-mac-debug'), + ).toBeTruthy(); + expect(ui.queryByTestId('sync-reconnect-loader-iphone-debug')).toBeNull(); + expect(ui.getByTestId('sync-reconnect-iphone-debug')).toBeTruthy(); + + finishReconnect(); + await waitFor(() => + expect(ui.queryByTestId('sync-reconnect-loader-mac-debug')).toBeNull(), + ); + }); +}); diff --git a/__tests__/pro/sync/ambientShare.integration.test.tsx b/__tests__/pro/sync/ambientShare.integration.test.tsx index 0aeec5ec8..554e32c1b 100644 --- a/__tests__/pro/sync/ambientShare.integration.test.tsx +++ b/__tests__/pro/sync/ambientShare.integration.test.tsx @@ -240,7 +240,9 @@ describe('mobile ambient sharing journey', () => { entity: string, entityId: string, fields: Record, - ) => remoteRecords.set(`${entity}:${entityId}`, fields), + ) => { + remoteRecords.set(`${entity}:${entityId}`, fields); + }, remove: (entity: string, entityId: string) => remoteRecords.delete(`${entity}:${entityId}`), }, @@ -648,6 +650,7 @@ describe('mobile ambient sharing journey', () => { ).toBeTruthy(); fireEvent.press(ui.getByTestId('sync-file-filter-screenshot')); expect(ui.getByText(retriedScreenshot.name)).toBeTruthy(); + }, 30_000); async function captureScreenshot(options: { diff --git a/__tests__/pro/sync/clipboardSync.integration.test.tsx b/__tests__/pro/sync/clipboardSync.integration.test.tsx index fa2f31b0a..73b99b0ed 100644 --- a/__tests__/pro/sync/clipboardSync.integration.test.tsx +++ b/__tests__/pro/sync/clipboardSync.integration.test.tsx @@ -203,6 +203,53 @@ describe('mobile clipboard Sync journey', () => { jest.restoreAllMocks(); }); + it('rejects remote clipboard text as soon as the entitlement closes', async () => { + const nativeClipboard = new ClipboardBoundary(); + let receiveRemote: + | ((deviceId: string, channel: string, data: unknown) => void) + | undefined; + const service = new MobileClipboardSyncService({ + nativeClipboard, + preferences: new ClipboardPreferences(), + localDevice: async () => device('this-phone', 'ios'), + transport: { + sendApp: () => true, + connectedDeviceIds: () => ['paired-mac'], + thisDeviceName: () => 'This phone', + deviceName: () => 'Paired Mac', + onAppMessage: listener => { + receiveRemote = listener; + return () => { + receiveRemote = undefined; + }; + }, + }, + now: () => BASE_TIME, + }); + service.setEntitlementActive(true); + await service.setEnabled(true); + receiveRemote?.('paired-mac', CLIPBOARD_CHANNEL, { + t: 'text', + text: 'arrived before expiry', + ts: BASE_TIME, + }); + await waitFor(() => + expect(nativeClipboard.writes).toEqual(['arrived before expiry']), + ); + + service.setEntitlementActive(false); + receiveRemote?.('paired-mac', CLIPBOARD_CHANNEL, { + t: 'text', + text: 'arrived after expiry', + ts: BASE_TIME + 1, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(nativeClipboard.writes).toEqual(['arrived before expiry']); + expect(service.enabled()).toBe(false); + await service.stop(); + }); + it('syncs opted-in native clipboard text once over the encrypted app channel', async () => { const tcpModule = createNativeTcpBoundary() as RnTcpModule; const mobileDevice = device('mobile-clipboard', 'ios'); diff --git a/__tests__/pro/sync/deviceManagement.integration.test.tsx b/__tests__/pro/sync/deviceManagement.integration.test.tsx index e4ff14307..796efb40b 100644 --- a/__tests__/pro/sync/deviceManagement.integration.test.tsx +++ b/__tests__/pro/sync/deviceManagement.integration.test.tsx @@ -1,7 +1,9 @@ import React from 'react'; +import { NativeModules, StyleSheet } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { fireEvent, + act, render, waitFor, within, @@ -9,8 +11,21 @@ import { } from '@testing-library/react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import TcpSocket from 'react-native-tcp-socket'; -import type { DeviceInfo } from '@offgrid/sync'; +import QRCode from 'react-native-qrcode-svg'; +import { + OFFGRID_SYNC_PORT, + PAIRING_QR_VALIDITY_MS, + encodePairingQrPayload, + parsePairingCode, + parsePairingQrPayload, + type DeviceInfo, +} from '@offgrid/sync'; import type { RnTcpModule } from '@offgrid/sync/rn'; +import { + useCameraDevice, + useCameraPermission, + useCodeScanner, +} from 'react-native-vision-camera'; import { AppNavigator } from '../../../src/navigation/AppNavigator'; import { registerScreen, @@ -31,7 +46,11 @@ import { SyncHomeCard } from '../../../pro/ui/SyncHomeCard'; import { ProRoot } from '../../../pro/ui/ProRoot'; import { getDiscoveryBoundaries, + getTcpDials, resetDiscoveryBoundaries, + resetTcpDials, + resetTcpPortRoutes, + routeTcpPort, } from '../../utils/nativeSyncBoundaries'; import { pairingCodeOnScreen, @@ -86,6 +105,7 @@ const mesh = createLicensedMesh(); describe('Pro mobile saved-device management journey', () => { let remote: ReturnType | undefined; + let extraRemotes: ReturnType[] = []; let ui: ReturnType | undefined; let secrets: Map; /** What the pairing store has actually written, read back out of the Keychain the app used. */ @@ -95,8 +115,10 @@ describe('Pro mobile saved-device management journey', () => { beforeEach(async () => { mesh.reset(); + extraRemotes = []; await AsyncStorage.clear(); resetDiscoveryBoundaries(); + resetTcpPortRoutes(); _clearScreensForTesting(); _clearSlotsForTesting(); _clearSectionsForTesting(); @@ -129,15 +151,377 @@ describe('Pro mobile saved-device management journey', () => { }); afterEach(async () => { + jest.useRealTimers(); mesh.restore(); ui?.unmount(); await remote?.engine.stop(); + await Promise.all(extraRemotes.map(peer => peer.engine.stop())); await syncService.stop(); _clearScreensForTesting(); _clearSlotsForTesting(); _clearSectionsForTesting(); + useAppStore.getState().setThemeMode('system'); + delete (NativeModules as Record).SyncBlobChannelModule; + (useCameraDevice as jest.Mock).mockReturnValue(undefined); + (useCameraPermission as jest.Mock).mockReturnValue({ + hasPermission: false, + requestPermission: jest.fn(), + }); + (useCodeScanner as jest.Mock).mockClear(); + }); + + it('shows the current pairing QR on demand and updates it after rotation', async () => { + (NativeModules as Record).SyncBlobChannelModule = { + lanAddress: jest.fn(async () => '192.168.1.25'), + interfaceCandidates: jest.fn(async () => [ + { host: '192.168.1.25', interfaceName: 'en0' }, + { host: '100.70.80.90', interfaceName: 'utun4' }, + ]), + }; + useAppStore.getState().setThemeMode('dark'); + await syncService.start(); + ui = render( + + + , + ); + + const firstCode = await pairingCodeOnScreen(ui); + expect(ui.getByLabelText('Show pairing QR code')).toBeTruthy(); + expect(ui.queryByTestId('sync-pairing-qr')).toBeNull(); + const codeRow = within(ui.getByTestId('sync-pairing-code-row')); + const actionRow = within(ui.getByTestId('sync-pairing-code-actions')); + expect(codeRow.getByTestId('sync-pairing-code-value')).toBeTruthy(); + expect(actionRow.getByTestId('sync-rotate-pairing-code')).toBeTruthy(); + const showQr = ui.getByTestId('sync-show-pairing-qr'); + const scanQr = ui.getByTestId('sync-open-pairing-scanner'); + expect(actionRow.getByTestId('sync-show-pairing-qr')).toBe(showQr); + expect(actionRow.getByTestId('sync-open-pairing-scanner')).toBe(scanQr); + expect(StyleSheet.flatten(showQr.props.style)).toEqual( + expect.objectContaining({ width: 44, height: 44 }), + ); + expect(StyleSheet.flatten(scanQr.props.style)).toEqual( + expect.objectContaining({ width: 44, height: 44 }), + ); + expect( + ( + NativeModules.SyncBlobChannelModule as { + interfaceCandidates: jest.Mock; + } + ).interfaceCandidates, + ).not.toHaveBeenCalled(); + + fireEvent.press(ui.getByTestId('sync-show-pairing-qr')); + expect(ui.getByTestId('sync-pairing-qr-loading')).toBeTruthy(); + const firstQr = await waitFor(() => ui!.getByTestId('sync-pairing-qr')); + expect(firstQr.props.accessibilityRole).toBe('image'); + expect(firstQr.props.accessibilityValue).toBeUndefined(); + const firstQrSvg = ui.UNSAFE_getByType(QRCode); + const firstValue = firstQrSvg.props.value as string; + const firstPayload = parsePairingQrPayload(firstValue); + expect(firstPayload).toEqual( + expect.objectContaining({ + device: expect.objectContaining({ id: PHONE_FINGERPRINT }), + pairingCode: parsePairingCode(firstCode), + routes: [ + { kind: 'lan', host: '192.168.1.25', port: OFFGRID_SYNC_PORT }, + { + kind: 'tailscale', + host: '100.70.80.90', + port: OFFGRID_SYNC_PORT, + }, + ], + issuedAt: expect.any(Number), + }), + ); + expect(firstQr.props.accessibilityHint).toMatch(/pairing code/i); + expect(firstQrSvg.props.value).toBe(firstValue); + expect(firstQrSvg.props.ecl).toBe('H'); + expect(firstQrSvg.props.logo).toBeTruthy(); + + useAppStore.getState().setThemeMode('light'); + expect( + await waitFor(() => ui!.getByTestId('sync-pairing-qr')), + ).toBeTruthy(); + fireEvent.press(ui.getByText('Close')); + await waitFor(() => + expect(ui!.queryByTestId('sync-pairing-qr')).toBeNull(), + ); + + fireEvent.press(ui.getByTestId('sync-rotate-pairing-code')); + await waitFor(() => + expect( + ui!.getByTestId('sync-pairing-code-value').props.children, + ).not.toBe(firstCode), + ); + const nextCode = await pairingCodeOnScreen(ui); + jest.useFakeTimers({ now: Date.now() }); + fireEvent.press(ui.getByTestId('sync-show-pairing-qr')); + expect(ui.getByTestId('sync-pairing-qr-loading')).toBeTruthy(); + const rotatedQr = await waitFor(() => ui!.getByTestId('sync-pairing-qr')); + expect(rotatedQr.props.accessibilityValue).toBeUndefined(); + const rotatedValue = ui.UNSAFE_getByType(QRCode).props.value as string; + expect(parsePairingQrPayload(rotatedValue)).toEqual( + expect.objectContaining({ pairingCode: parsePairingCode(nextCode) }), + ); + expect(rotatedValue).not.toBe(firstValue); + expect(nextCode).not.toBe(firstCode); + + const rotatedPayload = parsePairingQrPayload(rotatedValue); + await act(async () => { + jest.advanceTimersByTime(PAIRING_QR_VALIDITY_MS - 60_000); + await Promise.resolve(); + }); + const refreshedValue = ui.UNSAFE_getByType(QRCode).props.value as string; + const refreshedPayload = parsePairingQrPayload(refreshedValue); + expect(refreshedValue).not.toBe(rotatedValue); + expect(refreshedPayload).toEqual( + expect.objectContaining({ + device: rotatedPayload?.device, + pairingCode: rotatedPayload?.pairingCode, + routes: rotatedPayload?.routes, + issuedAt: expect.any(Number), + }), + ); + expect(refreshedPayload!.issuedAt).toBeGreaterThan( + rotatedPayload!.issuedAt, + ); + jest.useRealTimers(); + }); + + it('keeps the QR action unavailable until the pairing code is ready', () => { + ui = render( + + + , + ); + + expect(ui.getByTestId('sync-pairing-code-value').props.children).toBe( + 'Loading...', + ); + expect( + ui.getByTestId('sync-show-pairing-qr').props.accessibilityState.disabled, + ).toBe(true); + fireEvent.press(ui.getByTestId('sync-show-pairing-qr')); + expect(ui.queryByTestId('sync-pairing-qr')).toBeNull(); }); + it('shows one visible scanner, rejects an expired code, then pairs the exact QR device and route', async () => { + mesh.register({ + id: 'desktop-qr-peer', + name: 'QR Desktop', + platform: 'macos', + }); + const remoteDevice: DeviceInfo = { + id: 'desktop-qr-peer', + name: 'QR Desktop', + platform: 'macos', + version: '1', + host: '192.168.1.90', + port: 0, + }; + const persistence = new MembershipPersistenceBoundary(); + remote = buildSyncEngine({ + pairingEntitlement: mesh.peer(), + localDevice: remoteDevice, + tcpModule: nativeTcpBoundary, + getPassphrase: async () => TYPED_PAIRING_CODE, + getSharedSecret: deviceId => + persistence.getActive(deviceId)?.sharedSecret, + pairingPersistence: persistence, + membershipPersistence: persistence, + }); + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + await syncService.start(); + ui = render( + <> + + + + + , + ); + + fireEvent.press(ui.getByTestId('sync-open-pairing-scanner')); + expect(ui.getByText('Camera access needed')).toBeTruthy(); + expect(ui.getAllByTestId('qr-scanner-close')).toHaveLength(1); + fireEvent.press(ui.getByTestId('qr-scanner-close')); + + (useCameraDevice as jest.Mock).mockReturnValue({ id: 'back-camera' }); + (useCameraPermission as jest.Mock).mockReturnValue({ + hasPermission: true, + requestPermission: jest.fn(), + }); + fireEvent.press(ui.getByTestId('sync-open-pairing-scanner')); + const scanner = (useCodeScanner as jest.Mock).mock.calls.at(-1)?.[0] as { + onCodeScanned(codes: { value: string }[]): void; + }; + const encode = (now: number) => + encodePairingQrPayload( + { + device: remoteDevice, + pairingCode: TYPED_PAIRING_CODE, + routes: [ + { + kind: 'lan', + host: remoteDevice.host, + port: remoteDevice.port, + }, + ], + }, + now, + ); + + act(() => { + scanner.onCodeScanned([ + { value: encode(Date.now() - PAIRING_QR_VALIDITY_MS - 1) }, + ]); + }); + expect(ui.getByText(/This QR code has expired/)).toBeTruthy(); + + act(() => { + scanner.onCodeScanned([{ value: encode(Date.now()) }]); + }); + expect(ui.getAllByText('Connecting to QR Desktop').length).toBeGreaterThan( + 0, + ); + await waitFor( + () => + expect( + within(ui!.getByTestId('sync-paired-desktop-qr-peer')).getByText( + /Connected/, + ), + ).toBeTruthy(), + { timeout: 10_000 }, + ); + await waitFor(() => { + const failure = ui!.queryByTestId('qr-scanner-status'); + if (failure) throw new Error(`scanner failed: ${failure.props.children}`); + expect(ui!.queryByTestId('qr-scanner-close')).toBeNull(); + }); + expect( + getTcpDials().some( + dial => + dial.host === remoteDevice.host && dial.port === remoteDevice.port, + ), + ).toBe(true); + }, 20_000); + + it('keeps Rescan running when one saved peer is unreachable and another is reachable', async () => { + const devices: DeviceInfo[] = [ + { + id: 'desktop-unreachable', + name: 'Studio Desktop', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }, + { + id: 'desktop-reachable', + name: 'Travel Desktop', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }, + ]; + for (const device of devices) { + mesh.register({ + id: device.id, + name: device.name, + platform: device.platform, + }); + const persistence = new MembershipPersistenceBoundary(); + const peer = buildSyncEngine({ + pairingEntitlement: mesh.peer(), + localDevice: device, + tcpModule: nativeTcpBoundary, + getPassphrase: async () => TYPED_PAIRING_CODE, + getSharedSecret: deviceId => + persistence.getActive(deviceId)?.sharedSecret, + pairingPersistence: persistence, + membershipPersistence: persistence, + }); + await peer.engine.start(0); + device.port = peer.transport.boundPort ?? 0; + extraRemotes.push(peer); + } + + await syncService.start(); + ui = render( + + + , + ); + const mobile = useSyncStore.getState().thisDevice; + const discovery = getDiscoveryBoundaries().at(-1); + if (!mobile || !discovery?.publishedPort) { + throw new Error('Sync did not publish the mobile device'); + } + const code = await pairingCodeOnScreen(ui); + for (const peer of extraRemotes) { + await peer.engine.pair( + { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, + code, + ); + } + await waitFor(() => + expect(ui!.getByTestId('sync-paired-desktop-unreachable')).toBeTruthy(), + ); + await waitFor(() => + expect(ui!.getByTestId('sync-paired-desktop-reachable')).toBeTruthy(), + ); + + await extraRemotes[0].engine.stop(); + discovery.lose(devices[0].id); + await waitFor(() => + expect( + within(ui!.getByTestId('sync-paired-desktop-unreachable')).getByText( + /Offline/, + ), + ).toBeTruthy(), + ); + + await waitFor(() => expect(ui!.queryByTestId('sync-scanning')).toBeNull()); + fireEvent.press(ui.getByTestId('sync-rescan')); + await waitFor(() => expect(ui!.getByTestId('sync-scanning')).toBeTruthy()); + discovery.resolve(devices[0]); + discovery.resolve(devices[1]); + + await waitFor( + () => + expect( + getTcpDials().some( + dial => dial.port === devices[0].port && dial.refused, + ), + ).toBe(true), + { timeout: 10_000 }, + ); + await waitFor(() => + expect( + useSyncStore.getState().reachabilityErrorByDeviceId[devices[0].id], + ).toBeTruthy(), + ); + await waitFor( + () => + expect( + within(ui!.getByTestId('sync-paired-desktop-unreachable')).getByText( + /Could not reach/, + ), + ).toBeTruthy(), + { timeout: 10_000 }, + ); + expect( + within(ui.getByTestId('sync-paired-desktop-reachable')).getByText( + /Connected/, + ), + ).toBeTruthy(); + expect(ui.queryByTestId('sync-rescan-error')).toBeNull(); + expect(ui.getByTestId('sync-reconnect-desktop-unreachable')).toBeTruthy(); + }, 20_000); + it('disconnects, reconnects, pairs again from an offline row, and forgets a paired desktop', async () => { // This desktop has been on the licence all along, as a real paired peer would be: the roster is // built from installations, so a peer with none is a peer the phone cannot show. @@ -209,7 +593,9 @@ describe('Pro mobile saved-device management journey', () => { expect(within(connectedRow).getByText(/Connected · WiFi/)).toBeTruthy(); expect(within(connectedRow).queryByLabelText(/Rename/)).toBeNull(); fireEvent.press(ui.getByTestId('sync-rename-this-device')); - expect(await waitFor(() => ui!.getByText('Rename this device'))).toBeTruthy(); + expect( + await waitFor(() => ui!.getByText('Rename this device')), + ).toBeTruthy(); fireEvent.changeText( ui.getByTestId('sync-rename-this-device-input'), 'Travel Phone', @@ -420,6 +806,161 @@ describe('Pro mobile saved-device management journey', () => { ).toEqual([remoteDevice.id]); }); + it('saves one private endpoint and reconnects only to that address after restart', async () => { + mesh.register({ + id: 'desktop-private-peer', + name: 'Travel Desktop', + platform: 'macos', + }); + const remoteDevice: DeviceInfo = { + id: 'desktop-private-peer', + name: 'Travel Desktop', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }; + const remotePersistence = new MembershipPersistenceBoundary(); + remote = buildSyncEngine({ + pairingEntitlement: mesh.peer(), + localDevice: remoteDevice, + tcpModule: nativeTcpBoundary, + getPassphrase: async () => TYPED_PAIRING_CODE, + getSharedSecret: deviceId => + remotePersistence.getActive(deviceId)?.sharedSecret, + pairingPersistence: remotePersistence, + membershipPersistence: remotePersistence, + }); + await remote.engine.start(0); + remoteDevice.port = remote.transport.boundPort ?? 0; + await syncService.start(); + + ui = render( + <> + + + + + , + ); + await waitFor(() => expect(ui!.getByTestId('sync-home-card')).toBeTruthy()); + fireEvent.press(ui.getByTestId('open-sync-from-home')); + + const mobile = useSyncStore.getState().thisDevice; + const discovery = getDiscoveryBoundaries().at(-1); + if (!mobile || !discovery?.publishedPort) { + throw new Error('Sync did not publish the mobile device'); + } + await remote.engine.pair( + { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, + await pairingCodeOnScreen(ui), + ); + await waitFor(() => + expect( + within(ui!.getByTestId(`sync-paired-${remoteDevice.id}`)).getByText( + /Connected/, + ), + ).toBeTruthy(), + ); + + fireEvent.press(ui.getByTestId(`sync-disconnect-${remoteDevice.id}`)); + await waitFor(() => + expect( + within(ui!.getByTestId(`sync-paired-${remoteDevice.id}`)).getByText( + /Offline/, + ), + ).toBeTruthy(), + ); + + fireEvent.press(ui.getByTestId(`sync-manual-endpoint-${remoteDevice.id}`)); + expect( + await waitFor(() => ui!.getByText('Connect by address')), + ).toBeTruthy(); + expect(ui.getByText(/Only your devices can read it/)).toBeTruthy(); + fireEvent.changeText( + ui.getByTestId('sync-manual-address-input'), + '100.100.20.30', + ); + expect(ui.queryByTestId('sync-manual-port-input')).toBeNull(); + routeTcpPort(OFFGRID_SYNC_PORT, remoteDevice.port); + const scansBeforeConnect = discovery.scanCount; + resetTcpDials(); + fireEvent.press(ui.getByTestId('sync-manual-endpoint-connect')); + + await waitFor(() => + expect( + within(ui!.getByTestId(`sync-paired-${remoteDevice.id}`)).getByText( + /Connected/, + ), + ).toBeTruthy(), + ); + expect(getTcpDials()).toContainEqual({ + host: '100.100.20.30', + port: OFFGRID_SYNC_PORT, + }); + expect(discovery.scanCount).toBe(scansBeforeConnect); + + await syncService.stop(); + ui.unmount(); + ui = undefined; + await syncService.start(); + const restartedDiscovery = getDiscoveryBoundaries().at(-1); + if (!restartedDiscovery) throw new Error('Sync discovery did not restart'); + expect(syncService.manualEndpoint(remoteDevice.id)).toEqual({ + deviceId: remoteDevice.id, + host: '100.100.20.30', + }); + resetTcpDials(); + await syncService.reconnectDevice(remoteDevice.id); + + expect(getTcpDials()).toContainEqual({ + host: '100.100.20.30', + port: OFFGRID_SYNC_PORT, + }); + }); + + it('stops nearby browsing and keeps the saved Sync port after restart', async () => { + await syncService.start(); + ui = render( + <> + + + + + , + ); + await waitFor(() => expect(ui!.getByTestId('sync-home-card')).toBeTruthy()); + fireEvent.press(ui.getByTestId('open-sync-from-home')); + + const discovery = getDiscoveryBoundaries().at(-1); + if (!discovery) throw new Error('Sync discovery did not start'); + const stopsBefore = discovery.stopCount; + fireEvent(ui.getByTestId('sync-toggle-browsing'), 'valueChange', false); + await waitFor(() => { + expect(discovery.stopCount).toBeGreaterThan(stopsBefore); + expect(ui!.getByTestId('sync-browsing-off')).toBeTruthy(); + }); + + fireEvent.press(ui.getByTestId('sync-open-connection-settings')); + expect( + await waitFor(() => ui!.getByTestId('sync-port-input')), + ).toBeTruthy(); + fireEvent.changeText(ui.getByTestId('sync-port-input'), '40123'); + fireEvent.press(ui.getByTestId('sync-port-save')); + await waitFor(() => { + expect(ui!.queryByTestId('sync-port-input')).toBeNull(); + expect(useSyncStore.getState().syncPort).toBe(40123); + }); + + await syncService.stop(); + ui.unmount(); + ui = undefined; + await syncService.start(); + + expect(useSyncStore.getState().browsing).toBe(false); + expect(useSyncStore.getState().syncPort).toBe(40123); + }); + it('shows Mobile-initiated cancel, code, and persistence failures before a clean retry', async () => { // This desktop holds an installation, as any licensed Mac does. Reconciliation RETIRES a device it // finds locally trusted but absent from the licence, so an unregistered peer is un-pairable by diff --git a/__tests__/pro/sync/discoverabilityControl.integration.test.ts b/__tests__/pro/sync/discoverabilityControl.integration.test.ts new file mode 100644 index 000000000..e11d2719c --- /dev/null +++ b/__tests__/pro/sync/discoverabilityControl.integration.test.ts @@ -0,0 +1,93 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { NativeSync } from '../../../src/services/sync/nativeSync'; +import { discoverabilityControl } from '../../../pro/sync/discoverabilityControl'; +import { DiscoverabilityPreference } from '../../../pro/sync/discoverabilityPreference'; +import { useSyncStore } from '../../../pro/sync/syncStore'; + +class DiscoverabilityRuntimeBoundary { + readonly calls: boolean[] = []; + current = true; + failure: Error | undefined; + + async setDiscoverable(next: boolean): Promise { + this.calls.push(next); + if (this.failure) throw this.failure; + this.current = next; + return this.current; + } + + isDiscoverable(): boolean { + return this.current; + } +} + +describe('discoverability state follows the native result', () => { + beforeEach(async () => { + await AsyncStorage.clear(); + await discoverabilityControl.hydrate(true); + discoverabilityControl.bind(() => null); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('keeps UI and storage on the last true state when native stop fails, then retries', async () => { + const runtime = new DiscoverabilityRuntimeBoundary(); + await new DiscoverabilityPreference().set(true); + await discoverabilityControl.hydrate(true); + discoverabilityControl.bind(() => runtime as unknown as NativeSync); + runtime.failure = new Error('iOS is still advertising.'); + + await expect(discoverabilityControl.set(false)).rejects.toThrow( + 'iOS is still advertising.', + ); + + expect(runtime.current).toBe(true); + expect(useSyncStore.getState()).toMatchObject({ + discoverable: true, + discoverablePending: false, + }); + await expect( + new DiscoverabilityPreference().load(), + ).resolves.toBe(true); + + runtime.failure = undefined; + await expect(discoverabilityControl.set(false)).resolves.toBe(false); + expect(runtime.current).toBe(false); + expect(useSyncStore.getState()).toMatchObject({ + discoverable: false, + discoverablePending: false, + }); + await expect( + new DiscoverabilityPreference().load(), + ).resolves.toBe(false); + expect(runtime.calls).toEqual([false, false]); + }); + + it('rolls native back when persistence fails, then applies the next retry', async () => { + const runtime = new DiscoverabilityRuntimeBoundary(); + await new DiscoverabilityPreference().set(true); + await discoverabilityControl.hydrate(true); + discoverabilityControl.bind(() => runtime as unknown as NativeSync); + jest + .spyOn(AsyncStorage, 'setItem') + .mockRejectedValueOnce(new Error('Storage is unavailable.')); + + await expect(discoverabilityControl.set(false)).rejects.toThrow( + 'Storage is unavailable.', + ); + + expect(runtime.current).toBe(true); + expect(runtime.calls).toEqual([false, true]); + expect(useSyncStore.getState()).toMatchObject({ + discoverable: true, + discoverablePending: false, + }); + await expect( + new DiscoverabilityPreference().load(), + ).resolves.toBe(true); + + await expect(discoverabilityControl.set(false)).resolves.toBe(false); + expect(runtime.current).toBe(false); + expect(runtime.calls).toEqual([false, true, false]); + }); +}); diff --git a/__tests__/pro/sync/meshResidencyTruth.integration.test.tsx b/__tests__/pro/sync/meshResidencyTruth.integration.test.tsx new file mode 100644 index 000000000..e6304e93c --- /dev/null +++ b/__tests__/pro/sync/meshResidencyTruth.integration.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { NativeModules } from 'react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { act, render } from '@testing-library/react-native'; +import { meshResidencyPolicy } from '../../../pro/sync/meshResidency'; +import { useSyncStore } from '../../../pro/sync/syncStore'; +import { SyncScreen } from '../../../pro/ui/SyncScreen'; + +jest.unmock('@react-navigation/native'); + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +const nativeResidency = { + begin: jest.fn(async () => ({ + status: 'foreground_only', + reason: 'timed_out', + })), + end: jest.fn(async () => undefined), + state: jest.fn(async () => ({ + status: 'foreground_only', + reason: 'timed_out', + })), + getConstants: jest.fn(() => ({ + survivesBackground: true, + backgroundGraceSeconds: null, + showsOngoingIndicator: true, + })), +}; + +describe('Android mesh residency truth in the rendered Sync journey', () => { + beforeEach(async () => { + await meshResidencyPolicy.release(); + useSyncStore.getState().reset(); + useSyncStore.getState().setThisDevice({ + id: 'this-phone', + name: 'This phone', + platform: 'android', + version: '107', + host: '127.0.0.1', + port: 42069, + }); + useSyncStore.getState().setStatus('running'); + useSyncStore.getState().setDiscoverable(true); + (NativeModules as unknown as Record).MeshResidencyModule = + nativeResidency; + }); + + afterEach(async () => { + await meshResidencyPolicy.release(); + delete (NativeModules as unknown as Record) + .MeshResidencyModule; + }); + + it('replaces a false background-running claim with foreground-only guidance', async () => { + const ui = render( + + + , + ); + + await act(async () => { + await meshResidencyPolicy.hold(); + }); + + expect(ui.queryByText('Foreground only')).toBeNull(); + expect(ui.getByTestId('sync-residency-notice').props.children).toBe( + 'Sync works while Off Grid AI Mobile is open. Android could not keep this device reachable in the background.', + ); + expect(ui.queryByText('Running in background')).toBeNull(); + }); + + it('keeps the Discoverable toggle as the status owner when advertising health is stale', () => { + useSyncStore.getState().setRuntimeHealth({ + transport: { + listener: { state: 'ready' }, + routes: [{ id: 'lan', state: 'ready' }], + }, + discovery: { + routes: [ + { + id: 'lan', + browse: { state: 'ready' }, + advertise: { + state: 'failed', + error: 'Nearby network access is unavailable.', + }, + peerCount: 0, + }, + ], + }, + scan: { state: 'settled', finishedAt: Date.now() }, + peerCount: 0, + }); + + const ui = render( + + + , + ); + + expect(ui.getByTestId('sync-toggle-discoverable')).toBeTruthy(); + expect(ui.queryByText('Not discoverable')).toBeNull(); + expect(ui.getByText(/Other devices cannot find this device/)).toBeTruthy(); + }); +}); diff --git a/__tests__/pro/sync/modelTransfer.integration.test.tsx b/__tests__/pro/sync/modelTransfer.integration.test.tsx index d143aa0ce..282fd3156 100644 --- a/__tests__/pro/sync/modelTransfer.integration.test.tsx +++ b/__tests__/pro/sync/modelTransfer.integration.test.tsx @@ -372,6 +372,13 @@ describe('Pro mobile model transfer journey', () => { ).resolves.toBe(false); fireEvent.press(ui.getByLabelText('Back')); + const whisperPath = `${modelTransferFsBoundary.DocumentDirectoryPath}/whisper-models/ggml-base.bin`; + const whisperBytes = Buffer.alloc(11 * 1024 * 1024); + await modelTransferFsBoundary.module.writeFile( + whisperPath, + whisperBytes.toString('base64'), + 'base64', + ); fireEvent.press(ui.getByTestId(`sync-send-model-${remoteDevice.id}`)); await waitFor(() => expect( @@ -402,14 +409,34 @@ describe('Pro mobile model transfer journey', () => { ), ).toBeTruthy(), ); + // The same aggregated picker resolves Whisper from its own disk registry and sends the real + // package to a Mac. This is the Android/iOS -> macOS route that was absent when transfer queried + // only the text-model registry. + returnedModel = undefined; + returnedFileName = undefined; + fireEvent.press( + ui.getByTestId('transfer-model-ggerganov/whisper.cpp/base'), + ); + fireEvent.press(ui.getByTestId('send-selected-model')); + await waitFor( + () => + expect( + ui!.getByText(`Whisper Base is available on ${remoteDevice.name}.`), + ).toBeTruthy(), + // This moves a real 11 MB model through the transport. The full pre-push suite runs other + // integration tests at the same time, so allow the transfer to finish under that load. + { timeout: 60000 }, + ); + expect(returnedFileName).toBe('ggml-base.bin'); + expect(returnedModel).toEqual(whisperBytes); // Not asserted: the sheet's "Sent " progress line. The completion state replaces it, so // matching it means catching a moment that has already passed - and the outcome is covered twice // over, by the sentence the user reads and by the peer holding the exact bytes. - }); + }, 90_000); // A phone whose every model is vision-capable used to be told it had nothing to send: the send side // refused any model with an mmproj, while the receiving side had installed those packages all along. - it('offers a vision package to a paired device and withholds a runtime that device cannot run', async () => { + it('offers vision and Whisper packages to a Mac and withholds a runtime it cannot run', async () => { const vision = createVisionModel({ id: 'google/gemma-4-E2B/gemma-4-E2B-it-Q4_K_M.gguf', name: 'Gemma 4 E2B', @@ -447,10 +474,14 @@ describe('Pro mobile model transfer journey', () => { { ...liteRT, filePath: `${modelsDir}/${liteRT.fileName}` }, ]), ); - const iPhone: DeviceInfo = { - id: 'paired-iphone', - name: 'iPhone', - platform: 'ios', + modelTransferFsBoundary.seedFile( + `${modelTransferFsBoundary.DocumentDirectoryPath}/whisper-models/ggml-base.bin`, + 142 * 1024 * 1024, + ); + const mac: DeviceInfo = { + id: 'paired-mac', + name: 'Mac', + platform: 'macos', version: '1.0.0', host: '192.168.1.20', port: 51000, @@ -458,7 +489,7 @@ describe('Pro mobile model transfer journey', () => { ui = render( - {}} /> + {}} /> , ); @@ -468,6 +499,11 @@ describe('Pro mobile model transfer journey', () => { ); // LiteRT exists only on Android, so an iPhone is never offered one. expect(ui.queryByTestId(`transfer-model-${liteRT.id}`)).toBeNull(); + // Download Manager and model transfer both discover Whisper from its real disk registry. + expect( + ui.getByTestId('transfer-model-ggerganov/whisper.cpp/base'), + ).toBeTruthy(); + expect(ui.getByText('Whisper Base')).toBeTruthy(); // Its size is the whole package, not just the primary file. expect(ui.getByText(/4\.5 GB|4\.49 GB/)).toBeTruthy(); }); @@ -523,7 +559,8 @@ describe('Pro mobile model transfer journey', () => { direction: 'send', peerDeviceId: target.id, peerPlatform: target.platform, - modelId: moving.id, + modelId: 'model-package-v1:exact-transcription-variant', + requestedModelId: moving.id, modelName: moving.name, fileCount: 1, bytesTotal: moving.fileSize, diff --git a/__tests__/pro/sync/stateOpStore.integration.test.ts b/__tests__/pro/sync/stateOpStore.integration.test.ts new file mode 100644 index 000000000..b40370e7c --- /dev/null +++ b/__tests__/pro/sync/stateOpStore.integration.test.ts @@ -0,0 +1,79 @@ +import { installRealSqlite } from '../../harness/sqliteFake'; + +/** Uses the real Pro store and shared sync store with only the native SQLite binding replaced. */ +describe('Pro state op store startup', () => { + it('keeps the complete operation log across restart', async () => { + installRealSqlite(); + + const { StateOpStore } = require('../../../pro/sync/stateOpStore'); + const { countOps } = require('@offgrid/sync'); + const { opStoreDriver } = require('../../../pro/sync/opStoreDriver'); + const store = new StateOpStore(); + + await store.load(); + store.append({ + opId: 'old-task', + entity: 'task', + entityId: 'task-1', + kind: 'put', + fields: { text: 'old '.repeat(100_000) }, + lamport: 1, + deviceId: 'phone', + ts: 1, + }); + store.append({ + opId: 'current-task', + entity: 'task', + entityId: 'task-1', + kind: 'put', + fields: { text: 'current' }, + lamport: 2, + deviceId: 'phone', + ts: 2, + }); + expect(countOps(opStoreDriver)).toBe(2); + + const loaded = await new StateOpStore().load(); + + expect(loaded.map((op: { opId: string }) => op.opId)).toEqual([ + 'old-task', + 'current-task', + ]); + expect(countOps(opStoreDriver)).toBe(2); + }); + + it('keeps receive watermarks across restart', async () => { + installRealSqlite(); + + const { StateOpStore } = require('../../../pro/sync/stateOpStore'); + const store = new StateOpStore(); + await store.load(); + store.append({ + opId: 'phone-old-value', + entity: 'shared_file', + entityId: 'file-1', + kind: 'put', + fields: { name: 'old.png' }, + lamport: 40, + deviceId: 'phone', + ts: 1, + }); + store.append({ + opId: 'desktop-current-value', + entity: 'shared_file', + entityId: 'file-1', + kind: 'put', + fields: { name: 'current.png' }, + lamport: 41, + deviceId: 'desktop', + ts: 2, + }); + + const restarted = new StateOpStore(); + await restarted.load(); + + expect(restarted.entityVersionVector()).toEqual({ + shared_file: { phone: 40, desktop: 41 }, + }); + }); +}); diff --git a/__tests__/pro/sync/stateSync.integration.test.tsx b/__tests__/pro/sync/stateSync.integration.test.tsx index c9ef351ba..3dd9b3a5c 100644 --- a/__tests__/pro/sync/stateSync.integration.test.tsx +++ b/__tests__/pro/sync/stateSync.integration.test.tsx @@ -7,6 +7,10 @@ import TcpSocket from 'react-native-tcp-socket'; import { OpLog, StateSync, + TASK_LAUNCH_ENTITY, + TASK_RUN_ENTITY, + TASK_VISUAL_STEP_ENTITY, + taskVisualStepId, type DeviceInfo, type Materializer, } from '@offgrid/sync'; @@ -31,7 +35,11 @@ import { type SyncMutation, } from '../../../src/services/sync/mutation'; import { syncService } from '../../../pro/sync/syncService'; -import { stateSyncService } from '../../../pro/sync/stateSyncService'; +import { + STATE_CHANNEL, + stateSyncService, +} from '../../../pro/sync/stateSyncService'; +import { useTaskRunStore } from '../../../pro/tasks/taskRunStore'; import { useSyncStore } from '../../../pro/sync/syncStore'; import { SyncScreen } from '../../../pro/ui/SyncScreen'; import { SyncSharingSettingsScreen } from '../../../pro/ui/SyncScreen/SyncSharingSettingsScreen'; @@ -482,6 +490,7 @@ describe('Pro mobile state sync journey', () => { ui = undefined; await stateSyncService.stop(); await stateSyncService.start(); + await stateSyncService.whenReady(); // The log is collapsed at startup, so it comes back SMALLER, not identical - superseded ops are // dropped and only the winner for each record is kept. What has to survive is the state itself, // which the temperature below is read for. A log that came back empty, or bigger, would be wrong. @@ -505,4 +514,179 @@ describe('Pro mobile state sync journey', () => { winningTemperature.value, ); }); + + it('reconnects before slow owners finish and rejects forged task state', async () => { + let releaseSlowStartup: (() => void) | undefined; + const slowStartup = new Promise(resolve => { + releaseSlowStartup = resolve; + }); + await stateSyncService.start(slowStartup); + await syncService.start(); + expect(syncService.isRunning()).toBe(true); + + const remoteDevice: DeviceInfo = { + id: 'desktop-task-owner', + name: 'Office Mac', + platform: 'macos', + version: '1', + host: '127.0.0.1', + port: 0, + }; + let opIndex = 0; + const remoteLog = new OpLog({ + deviceId: remoteDevice.id, + deviceName: remoteDevice.name, + materializer: new RemoteRecords(), + uuid: () => `owner-op-${++opIndex}`, + now: () => Date.now(), + }); + const task = { + version: 1 as const, + launchId: 'launch-during-slow-startup', + requestingDeviceId: remoteDevice.id, + taskId: 'task-during-slow-startup', + conversationId: 'chat-during-slow-startup', + kind: 'computer_use' as const, + executionDevice: { + id: remoteDevice.id, + name: remoteDevice.name, + }, + title: 'Open the report', + status: 'running' as const, + progress: [], + startedAt: 1, + updatedAt: 1, + }; + remoteLog.record(TASK_LAUNCH_ENTITY, task.launchId, 'put', { + version: 1, + launchId: task.launchId, + conversationId: task.conversationId, + kind: task.kind, + requestingDeviceId: task.requestingDeviceId, + executionDeviceId: task.executionDevice.id, + requestedAt: 1, + }); + remoteLog.record(TASK_RUN_ENTITY, task.taskId, 'put', task); + const visualStep = { + version: 1 as const, + visualStepId: taskVisualStepId(task.taskId, 1), + taskId: task.taskId, + conversationId: task.conversationId, + sequence: 1, + executionDevice: task.executionDevice, + phase: 'observing', + actionLabel: 'Open the report', + frame: { + sequence: 1, + mimeType: 'image/jpeg' as const, + payloadBase64: 'c2NyZWVu', + width: 100, + height: 50, + capturedAt: 1, + }, + }; + remoteLog.record( + TASK_VISUAL_STEP_ENTITY, + visualStep.visualStepId, + 'put', + visualStep, + ); + let remoteState: StateSync; + remote = buildSyncEngine({ + pairingEntitlement: mesh.joiner({ + name: remoteDevice.name, + platform: remoteDevice.platform, + }), + localDevice: remoteDevice, + tcpModule: nativeTcpBoundary, + onPaired: device => remoteState.onConnect(device.id), + onAppMessage: (deviceId, channel, data) => { + if (channel === STATE_CHANNEL) remoteState.onMessage(deviceId, data); + }, + }); + remoteState = new StateSync({ + oplog: remoteLog, + send: (deviceId, message) => { + remote!.engine.sendApp(deviceId, STATE_CHANNEL, message); + }, + }); + await remote.engine.start(0); + + const mobile = useSyncStore.getState().thisDevice; + const discovery = getDiscoveryBoundaries().at(-1); + const pairingCode = useSyncStore.getState().pairingCode.code; + if (!mobile || !discovery?.publishedPort || !pairingCode) { + throw new Error( + 'Mobile Sync did not become available during slow startup', + ); + } + await remote.engine.pair( + { ...mobile, host: '127.0.0.1', port: discovery.publishedPort }, + pairingCode, + ); + await waitFor(() => + expect(syncService.connectedDeviceIds()).toContain(remoteDevice.id), + ); + await waitFor(() => + expect(useTaskRunStore.getState().runs[task.taskId]).toMatchObject({ + title: task.title, + executionDevice: task.executionDevice, + }), + ); + await waitFor(() => + expect( + useTaskRunStore.getState().visualSteps[visualStep.visualStepId], + ).toMatchObject({ actionLabel: visualStep.actionLabel }), + ); + + releaseSlowStartup?.(); + await stateSyncService.whenReady(); + + const ownerUpdate = remoteLog.record(TASK_RUN_ENTITY, task.taskId, 'put', { + ...task, + updatedAt: 2, + currentAction: 'Read the report', + }); + remote.engine.sendApp(mobile.id, STATE_CHANNEL, { + t: 'ops', + ops: [ + { + opId: 'forged-put', + entity: TASK_RUN_ENTITY, + entityId: 'forged-task', + kind: 'put', + fields: { ...task, taskId: 'forged-task' }, + lamport: 100, + deviceId: 'forged-device', + ts: 100, + provenance: { + originDeviceId: remoteDevice.id, + originDeviceName: remoteDevice.name, + }, + }, + { + opId: 'forged-delete', + entity: TASK_RUN_ENTITY, + entityId: task.taskId, + kind: 'delete', + lamport: 101, + deviceId: 'forged-device', + ts: 101, + provenance: { + originDeviceId: 'forged-device', + originDeviceName: 'Forged device', + }, + }, + ownerUpdate, + ], + }); + await waitFor(() => + expect(useTaskRunStore.getState().runs[task.taskId]?.updatedAt).toBe(2), + ); + expect(useTaskRunStore.getState().runs['forged-task']).toBeUndefined(); + expect(useTaskRunStore.getState().runs[task.taskId]?.title).toBe( + task.title, + ); + + }); }); diff --git a/__tests__/pro/sync/taskChat.integration.test.tsx b/__tests__/pro/sync/taskChat.integration.test.tsx new file mode 100644 index 000000000..ada3887a4 --- /dev/null +++ b/__tests__/pro/sync/taskChat.integration.test.tsx @@ -0,0 +1,279 @@ +import React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { + TASK_RUN_ENTITY, + TASK_VISUAL_STEP_ENTITY, + taskVisualStepId, +} from '@offgrid/sync'; +import { TaskChatCard } from '../../../pro/ui/TaskChatCard'; +import { MobileStateMaterializer } from '../../../pro/sync/mobileStateMaterializer'; +import { projectNotificationCenter } from '../../../pro/sync/notificationCenter'; +import { useTaskRunStore } from '../../../pro/tasks/taskRunStore'; +import { useSyncStore } from '../../../pro/sync/syncStore'; +import { useChatStore } from '../../../src/stores/chatStore'; + +jest.mock('react-native-tcp-socket', () => { + const { + createNativeTcpBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { + createNativeDiscoveryBoundary, + } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +const origin = { + originDeviceId: 'desktop-1', + originDeviceName: 'Office Mac', +}; + +const runningComputerTask = { + version: 1 as const, + launchId: 'launch-task-computer-1', + requestingDeviceId: 'phone-1', + taskId: 'task-computer-1', + conversationId: 'chat-mobile-1', + kind: 'computer_use' as const, + executionDevice: { id: 'desktop-1', name: 'Office Mac' }, + title: 'Send the project update in Slack', + status: 'running' as const, + phase: 'acting' as const, + currentStep: 4, + currentAction: 'Typing the message', + plan: { + phases: [ + { id: 'open', title: 'Open Slack' }, + { id: 'find', title: 'Find Ali' }, + { id: 'send', title: 'Send the update' }, + ], + activePhaseIndex: 2, + }, + progress: [ + { sequence: 1, label: 'Opened Slack', at: 10 }, + { sequence: 2, label: 'Found Ali', at: 20 }, + ], + frame: { + sequence: 2, + mimeType: 'image/jpeg' as const, + payloadBase64: 'aGVsbG8=', + width: 300, + height: 200, + capturedAt: 20, + }, + cursor: { x: 150, y: 100 }, + startedAt: 10, + updatedAt: 20, +}; + +function putConversation(materializer: MobileStateMaterializer): void { + materializer.put( + 'conversation', + runningComputerTask.conversationId, + { + title: runningComputerTask.title, + created_at: new Date(10).toISOString(), + updated_at: new Date(20).toISOString(), + project_id: null, + }, + origin, + ); + useChatStore + .getState() + .setActiveConversation(runningComputerTask.conversationId); +} + +describe('synced Web Use and Computer Use task in chat', () => { + const materializer = new MobileStateMaterializer(); + + beforeEach(() => { + useChatStore.getState().clearAllConversations(); + materializer.remove(TASK_RUN_ENTITY, runningComputerTask.taskId); + materializer.remove(TASK_RUN_ENTITY, 'task-web-1'); + materializer.remove( + TASK_VISUAL_STEP_ENTITY, + taskVisualStepId('task-web-1', 1), + ); + materializer.remove( + TASK_VISUAL_STEP_ENTITY, + taskVisualStepId('task-web-1', 2), + ); + useSyncStore.getState().setThisDevice({ + id: 'phone-1', + name: 'Ali phone', + platform: 'ios', + version: '1', + host: '', + port: 0, + }); + useSyncStore.getState().setConnectedDeviceIds(['desktop-1']); + putConversation(materializer); + }); + + it('shows the live Computer Use screen, cursor, and working controls in its chat', async () => { + materializer.put( + TASK_RUN_ENTITY, + runningComputerTask.taskId, + runningComputerTask, + origin, + ); + const screen = render( + , + ); + + expect(screen.getByText('COMPUTER USE')).toBeTruthy(); + expect(screen.getByText(runningComputerTask.title)).toBeTruthy(); + expect(screen.getByText('PLAN')).toBeTruthy(); + expect(screen.getByText('Open Slack')).toBeTruthy(); + expect(screen.getByText('Find Ali')).toBeTruthy(); + expect(screen.getByText('Send the update')).toBeTruthy(); + expect(screen.getAllByText('DONE')).toHaveLength(2); + expect(screen.getByText('NOW')).toBeTruthy(); + expect(screen.getByText('Typing the message')).toBeTruthy(); + expect(screen.getByText('ACTIVITY')).toBeTruthy(); + expect(screen.getByText('Opened Slack')).toBeTruthy(); + expect(screen.getByText('Found Ali')).toBeTruthy(); + act(() => + fireEvent(screen.getByTestId('task-session-frame'), 'layout', { + nativeEvent: { layout: { width: 300, height: 200, x: 0, y: 0 } }, + }), + ); + expect(screen.getByLabelText('Live view from Office Mac')).toBeTruthy(); + expect(screen.getByTestId('task-session-cursor')).toBeTruthy(); + expect(screen.getByText('LIVE VIEW')).toBeTruthy(); + expect(screen.queryByTestId('task-live-frame')).toBeNull(); + expect(screen.getAllByTestId('task-session-frame')).toHaveLength(1); + expect(screen.getByText('Pause')).toBeTruthy(); + expect(screen.getByText('Stop')).toBeTruthy(); + expect(screen.queryByText('Take Over')).toBeNull(); + expect(screen.queryByText('Approve')).toBeNull(); + expect(screen.queryByText('Decline')).toBeNull(); + + act(() => useSyncStore.getState().setConnectedDeviceIds([])); + expect( + screen.getByText( + 'Office Mac is offline. Progress and controls resume when it reconnects.', + ), + ).toBeTruthy(); + + fireEvent.press(screen.getByTestId('task-control-pause')); + await waitFor(() => + expect(screen.getByText('Pause requested')).toBeTruthy(), + ); + const request = + useTaskRunStore.getState().requestedControlByTaskId[ + runningComputerTask.taskId + ]; + + act(() => + materializer.put( + TASK_RUN_ENTITY, + runningComputerTask.taskId, + { + ...runningComputerTask, + status: 'paused', + phase: 'paused', + updatedAt: 30, + latestControlResult: { + controlId: request!.controlId, + kind: 'pause', + outcome: 'applied', + respondedAt: 30, + }, + }, + origin, + ), + ); + expect(screen.getByText('Resume')).toBeTruthy(); + expect(screen.queryByText('Pause requested')).toBeNull(); + expect(projectNotificationCenter([], 'all').badgeCount).toBe(0); + }); + + it('shows a failed Web Use task with a clear recovery and no live controls', () => { + materializer.put( + TASK_RUN_ENTITY, + 'task-web-1', + { + ...runningComputerTask, + taskId: 'task-web-1', + kind: 'web_use', + title: 'Find a flight to Pune', + status: 'failed', + phase: 'failed', + summary: 'No booking was made.', + failure: { + code: 'desktop_offline', + message: 'Office Mac disconnected before the task finished.', + recoverable: true, + recoveryAction: 'reconnect', + }, + finishedAt: 30, + updatedAt: 30, + }, + origin, + ); + for (const sequence of [1, 2]) { + const visualStepId = taskVisualStepId('task-web-1', sequence); + materializer.put( + TASK_VISUAL_STEP_ENTITY, + visualStepId, + { + version: 1, + visualStepId, + taskId: 'task-web-1', + conversationId: runningComputerTask.conversationId, + sequence, + executionDevice: runningComputerTask.executionDevice, + actionLabel: sequence === 1 ? 'Opened search' : 'Checked results', + frame: { + ...runningComputerTask.frame, + sequence, + capturedAt: sequence * 1_000, + }, + }, + origin, + ); + } + + const screen = render( + , + ); + expect(screen.getByText('WEB USE')).toBeTruthy(); + expect(screen.getByText('Find a flight to Pune')).toBeTruthy(); + expect(screen.getByText('No booking was made.')).toBeTruthy(); + expect( + screen.getByText('Office Mac disconnected before the task finished.'), + ).toBeTruthy(); + expect( + screen.getByText('Reconnect Office Mac and try again.'), + ).toBeTruthy(); + expect(screen.queryByText('Stop')).toBeNull(); + expect(screen.queryByText('Take Over')).toBeNull(); + expect(screen.getByText('SESSION REPLAY')).toBeTruthy(); + expect(screen.getByText('Step 1 of 2 · 0:00 / 0:01')).toBeTruthy(); + expect(screen.getByText('Play')).toBeTruthy(); + expect(screen.getByTestId('task-session-scrubber')).toBeTruthy(); + expect(screen.getAllByTestId('task-session-frame')).toHaveLength(1); + expect(screen.queryByTestId('task-live-frame')).toBeNull(); + const notifications = projectNotificationCenter([], 'all'); + expect(notifications.badgeCount).toBe(0); + expect(notifications.items).not.toContainEqual( + expect.objectContaining({ type: 'task-run' }), + ); + }); +}); diff --git a/__tests__/pro/tasks/companionTaskRouter.test.ts b/__tests__/pro/tasks/companionTaskRouter.test.ts new file mode 100644 index 000000000..0bb0c0302 --- /dev/null +++ b/__tests__/pro/tasks/companionTaskRouter.test.ts @@ -0,0 +1,111 @@ +import { + routeCompanionTask, + type CompanionTaskTool, +} from '../../../pro/tasks/companionTaskRouter'; + +const tool = (name: CompanionTaskTool) => ({ + name, + description: name, + inputSchema: { type: 'object' }, +}); + +function twoDesktopInput(task: CompanionTaskTool) { + return { + tool: task, + servers: [ + { + id: 'server-z', + name: 'Studio Mac', + grantedByDeviceId: 'desktop-b', + }, + { + id: 'server-a', + name: 'Office Mac', + grantedByDeviceId: 'desktop-a', + }, + ], + connectionStates: { + 'server-z': 'connected' as const, + 'server-a': 'connected' as const, + }, + serverTools: { + 'server-z': [tool(task)], + 'server-a': [tool(task)], + }, + enabledTools: [task], + devices: [ + { id: 'desktop-b', name: 'Release Mac', platform: 'macos' as const }, + { id: 'desktop-a', name: 'Desk', platform: 'windows' as const }, + ], + connectedDeviceIds: ['desktop-b', 'desktop-a'], + }; +} + +describe('Release 107 companion task router', () => { + it.each(['web_use', 'computer_use'] as const)( + 'uses one stable eligible default for %s across two Desktops', + task => { + expect(routeCompanionTask(twoDesktopInput(task))).toEqual({ + ok: true, + serverId: 'server-a', + deviceId: 'desktop-a', + }); + }, + ); + + it.each(['web_use', 'computer_use'] as const)( + 'matches an explicit %s Desktop name or alias without case sensitivity', + task => { + expect( + routeCompanionTask({ + ...twoDesktopInput(task), + requestedDevice: 'STUDIO MAC', + }), + ).toEqual({ + ok: true, + serverId: 'server-z', + deviceId: 'desktop-b', + }); + expect( + routeCompanionTask({ + ...twoDesktopInput(task), + requestedDevice: 'release mac', + }), + ).toEqual({ + ok: true, + serverId: 'server-z', + deviceId: 'desktop-b', + }); + }, + ); + + it('does not fall back when the named Desktop is offline', () => { + const input = twoDesktopInput('web_use'); + expect( + routeCompanionTask({ + ...input, + requestedDevice: 'Studio Mac', + connectedDeviceIds: ['desktop-a'], + }), + ).toEqual({ + ok: false, + error: + 'Studio Mac is not available for web_use. Connect it and enable this task tool, then try again.', + }); + }); + + it('does not fall back when the named Desktop has the task tool disabled', () => { + const input = twoDesktopInput('computer_use'); + expect( + routeCompanionTask({ + ...input, + requestedDevice: 'Studio Mac', + serverTools: { ...input.serverTools, 'server-z': [] }, + }), + ).toEqual({ + ok: false, + error: + 'Studio Mac is not available for computer_use. Connect it and enable this task tool, then try again.', + }); + }); +}); diff --git a/__tests__/pro/tasks/taskGuidanceService.test.tsx b/__tests__/pro/tasks/taskGuidanceService.test.tsx new file mode 100644 index 000000000..ea5d020b1 --- /dev/null +++ b/__tests__/pro/tasks/taskGuidanceService.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { TASK_GUIDANCE_CHANNEL, type SyncedTaskRun } from '@offgrid/sync'; +import { + TaskGuidanceService, + type TaskGuidanceTransport, +} from '../../../pro/tasks/taskGuidanceService'; +import { TaskGuidanceComposer } from '../../../pro/ui/task-card/TaskGuidanceComposer'; + +jest.mock('react-native-tcp-socket', () => { + const { createNativeTcpBoundary } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeTcpBoundary() }; +}); + +jest.mock('react-native-zeroconf', () => { + const { createNativeDiscoveryBoundary } = require('../../utils/nativeSyncBoundaries'); + return { __esModule: true, default: createNativeDiscoveryBoundary() }; +}); + +class GuidanceBoundary implements TaskGuidanceTransport { + sent: Array<{ deviceId: string; channel: string; data: any }> = []; + appListener?: (deviceId: string, channel: string, data: unknown) => void; + disconnectedListener?: (deviceId: string) => void; + thisDeviceId = () => 'phone-1'; + connectedDeviceIds = () => ['desktop-1']; + sendApp = (deviceId: string, channel: string, data: unknown): boolean => { + this.sent.push({ deviceId, channel, data }); + return true; + }; + onAppMessage = (listener: (deviceId: string, channel: string, data: unknown) => void) => { + this.appListener = listener; + return () => { this.appListener = undefined; }; + }; + onDisconnected = (listener: (deviceId: string) => void) => { + this.disconnectedListener = listener; + return () => { this.disconnectedListener = undefined; }; + }; +} + +const run: SyncedTaskRun = { + version: 1, + launchId: 'launch-1', + requestingDeviceId: 'phone-1', + taskId: 'task-1', + conversationId: 'chat-1', + kind: 'web_use', + executionDevice: { id: 'desktop-1', name: 'Studio Mac' }, + title: 'Find a flight', + status: 'running', + progress: [], + startedAt: 1, + updatedAt: 2, +}; + +describe('ephemeral Mobile task guidance', () => { + it('sends guidance outside StateSync and accepts only the execution Desktop acknowledgement', async () => { + const boundary = new GuidanceBoundary(); + const service = new TaskGuidanceService(boundary); + service.start(); + const pending = service.send(run, 'Use the existing account'); + const request = boundary.sent[0]; + expect(request).toMatchObject({ deviceId: 'desktop-1', channel: TASK_GUIDANCE_CHANNEL }); + expect(request.data).toMatchObject({ + type: 'guidance_request', + taskId: 'task-1', + text: 'Use the existing account', + }); + + boundary.appListener?.('other-device', TASK_GUIDANCE_CHANNEL, { + version: 1, + type: 'guidance_result', + guidanceId: request.data.guidanceId, + taskId: 'task-1', + outcome: 'accepted', + respondedAt: 3, + }); + let settled = false; + pending.finally(() => { settled = true; }); + await Promise.resolve(); + expect(settled).toBe(false); + + boundary.appListener?.('desktop-1', TASK_GUIDANCE_CHANNEL, { + version: 1, + type: 'guidance_result', + guidanceId: request.data.guidanceId, + taskId: 'task-1', + outcome: 'accepted', + respondedAt: 4, + }); + await expect(pending).resolves.toMatchObject({ outcome: 'accepted' }); + service.stop(); + }); + + it('renders the privacy boundary and clears guidance only after Desktop accepts it', async () => { + const boundary = new GuidanceBoundary(); + const service = new TaskGuidanceService(boundary); + service.start(); + const screen = render(); + expect(screen.getByText(/not saved in synced task history/)).toBeTruthy(); + fireEvent.changeText(screen.getByTestId('task-guidance-input'), 'Use the existing account'); + fireEvent.press(screen.getByTestId('task-guidance-send')); + const request = boundary.sent[0].data; + + act(() => boundary.appListener?.('desktop-1', TASK_GUIDANCE_CHANNEL, { + version: 1, + type: 'guidance_result', + guidanceId: request.guidanceId, + taskId: 'task-1', + outcome: 'accepted', + respondedAt: 4, + })); + + await waitFor(() => expect(screen.getByText('Guidance accepted by Studio Mac.')).toBeTruthy()); + expect(screen.getByTestId('task-guidance-input').props.value).toBe(''); + service.stop(); + }); +}); diff --git a/__tests__/pro/ui/modelTransferStatus.test.tsx b/__tests__/pro/ui/modelTransferStatus.test.tsx index 1133224cf..f2caeb9d3 100644 --- a/__tests__/pro/ui/modelTransferStatus.test.tsx +++ b/__tests__/pro/ui/modelTransferStatus.test.tsx @@ -161,7 +161,7 @@ describePro('the model transfer card', () => { expect(ui.queryByText('25%')).not.toBeNull(); }); - it('shows 0% rather than dividing by a total nobody has sent yet', () => { + it('shows indeterminate progress rather than dividing by a total nobody has sent yet', () => { // A queued transfer has no total until the offer is answered. NaN% is what an unguarded division renders. const ui = render( { />, ); - expect(ui.queryByText('0%')).not.toBeNull(); + expect(ui.queryByText('In progress')).not.toBeNull(); + expect(ui.queryByText('Rate unavailable')).not.toBeNull(); expect(ui.queryByText('NaN%')).toBeNull(); }); diff --git a/__tests__/pro/ui/taskSessionFullscreen.test.tsx b/__tests__/pro/ui/taskSessionFullscreen.test.tsx new file mode 100644 index 000000000..bb306953f --- /dev/null +++ b/__tests__/pro/ui/taskSessionFullscreen.test.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react-native'; +import type { SyncedTaskRun, SyncedTaskVisualStep } from '@offgrid/sync'; +import { TaskSessionPlayback } from '../../../pro/ui/task-card/TaskSessionPlayback'; + +jest.mock('@react-native-community/slider', () => ({ + __esModule: true, + default: (props: Record) => + require('react').createElement(require('react-native').View, props), +})); + +const frame = { + sequence: 2, + mimeType: 'image/jpeg' as const, + payloadBase64: '/9j/2Q==', + width: 300, + height: 200, + capturedAt: 2_000, +}; + +function run(status: SyncedTaskRun['status']): SyncedTaskRun { + return { + version: 1, + launchId: 'launch-fullscreen', + requestingDeviceId: 'phone-1', + taskId: 'task-fullscreen', + conversationId: 'chat-fullscreen', + kind: 'computer_use', + executionDevice: { id: 'desktop-1', name: 'Studio Mac' }, + title: 'Review the desktop app', + status, + phase: status === 'running' ? 'acting' : 'complete', + currentStep: 2, + progress: [], + frame: status === 'running' ? frame : undefined, + cursor: status === 'running' ? { x: 150, y: 100 } : undefined, + startedAt: 1_000, + updatedAt: 2_000, + finishedAt: status === 'running' ? undefined : 2_000, + }; +} + +const steps: SyncedTaskVisualStep[] = [ + { + version: 1, + visualStepId: 'task-fullscreen:1', + taskId: 'task-fullscreen', + conversationId: 'chat-fullscreen', + sequence: 1, + executionDevice: { id: 'desktop-1', name: 'Studio Mac' }, + actionLabel: 'Opened the app', + frame: { ...frame, sequence: 1, capturedAt: 1_000 }, + }, + { + version: 1, + visualStepId: 'task-fullscreen:2', + taskId: 'task-fullscreen', + conversationId: 'chat-fullscreen', + sequence: 2, + executionDevice: { id: 'desktop-1', name: 'Studio Mac' }, + actionLabel: 'Selected Continue', + frame, + cursor: { x: 150, y: 100 }, + }, +]; + +describe('TaskSessionPlayback full screen', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('opens the current live frame and cursor, then returns to the card', () => { + const screen = render( + , + ); + + fireEvent(screen.getByTestId('task-session-frame'), 'layout', { + nativeEvent: { layout: { width: 300, height: 200 } }, + }); + expect(screen.getByTestId('task-session-cursor')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('task-session-open-fullscreen')); + expect(screen.getByTestId('task-session-fullscreen')).toBeTruthy(); + expect(screen.queryByTestId('task-session-frame')).toBeNull(); + fireEvent(screen.getByTestId('task-session-fullscreen-frame'), 'layout', { + nativeEvent: { layout: { width: 390, height: 700 } }, + }); + expect(screen.getByLabelText('Live view from Studio Mac')).toBeTruthy(); + expect(screen.getByTestId('task-session-fullscreen-cursor')).toBeTruthy(); + + fireEvent.press(screen.getByLabelText('Close full screen')); + expect(screen.queryByTestId('task-session-fullscreen')).toBeNull(); + expect(screen.getByTestId('task-session-frame')).toBeTruthy(); + }); + + it('keeps replay position and play state across the full-screen transition', () => { + const screen = render( + , + ); + + fireEvent(screen.getByTestId('task-session-scrubber'), 'valueChange', 1); + expect(screen.getByText('Step 2 of 2 · 0:01 / 0:01')).toBeTruthy(); + expect(screen.getByText('Selected Continue')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('task-session-open-fullscreen')); + expect(screen.getByText('Step 2 of 2 · 0:01 / 0:01')).toBeTruthy(); + expect(screen.getByText('Selected Continue')).toBeTruthy(); + expect(screen.getByText('Play')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('task-session-fullscreen-toggle')); + expect(screen.getByText('Step 1 of 2 · 0:00 / 0:01')).toBeTruthy(); + expect(screen.getByText('Pause')).toBeTruthy(); + + fireEvent.press(screen.getByTestId('task-session-close-fullscreen')); + expect(screen.getByText('Step 1 of 2 · 0:00 / 0:01')).toBeTruthy(); + expect(screen.getByText('Pause')).toBeTruthy(); + + act(() => screen.unmount()); + }); +}); diff --git a/__tests__/pro/ui/transferActivitySection.test.tsx b/__tests__/pro/ui/transferActivitySection.test.tsx index 231e125c0..685cb5369 100644 --- a/__tests__/pro/ui/transferActivitySection.test.tsx +++ b/__tests__/pro/ui/transferActivitySection.test.tsx @@ -305,6 +305,31 @@ describePro('the Activity list', () => { expect(ui.getByText(/25%/)).toBeTruthy(); expect(ui.getByText(/MB \/ /)).toBeTruthy(); expect(ui.queryByText(/MB\/s/)).toBeNull(); + expect(ui.getByText(/Rate unavailable/)).toBeTruthy(); + }); + + it('shows the live rate for an ordinary file transfer', () => { + if (!guard()) return; + const acts = handlers(); + const ui = render( + , + ); + + expect(ui.getByText('4 MB / 8 MB · 1.5 MB/s')).toBeTruthy(); }); it('shows one model job instead of one raw row for every file in a vision package', async () => { diff --git a/__tests__/rntl/components/AppSheet.test.tsx b/__tests__/rntl/components/AppSheet.test.tsx index e6da01830..2d29d6382 100644 --- a/__tests__/rntl/components/AppSheet.test.tsx +++ b/__tests__/rntl/components/AppSheet.test.tsx @@ -16,7 +16,13 @@ */ import React from 'react'; -import { Text, Keyboard, Modal, TouchableWithoutFeedback, View } from 'react-native'; +import { + Text, + Keyboard, + Modal, + TouchableWithoutFeedback, + View, +} from 'react-native'; import { render, fireEvent, waitFor, act } from '@testing-library/react-native'; import { AppSheet } from '../../../src/components/AppSheet'; @@ -36,18 +42,14 @@ describe('AppSheet', () => { // ============================================================================ describe('visibility', () => { it('returns null when not visible and modalVisible is false', () => { - const { toJSON } = render( - - ); + const { toJSON } = render(); // When visible is false and internal modalVisible is false, renders null expect(toJSON()).toBeNull(); }); it('renders Modal when visible is true', () => { - const { toJSON } = render( - - ); + const { toJSON } = render(); // When visible is true, the component sets modalVisible=true and renders Modal expect(toJSON()).toBeTruthy(); @@ -60,7 +62,7 @@ describe('AppSheet', () => { describe('header', () => { it('shows title in header', () => { const { getByText } = render( - + , ); expect(getByText('My Sheet')).toBeTruthy(); @@ -68,7 +70,7 @@ describe('AppSheet', () => { it('shows close button with default "Done" label', () => { const { getByText } = render( - + , ); expect(getByText('Done')).toBeTruthy(); @@ -81,7 +83,7 @@ describe('AppSheet', () => { visible={true} title="Sheet" closeLabel="Cancel" - /> + />, ); expect(getByText('Cancel')).toBeTruthy(); @@ -94,7 +96,7 @@ describe('AppSheet', () => { visible={true} title="Hidden Title" showHeader={false} - /> + />, ); // Header title should not render when showHeader is false @@ -104,7 +106,7 @@ describe('AppSheet', () => { it('does not render header when title is not provided', () => { const { queryByText } = render( - + , ); // No title means no header row rendered (showHeader && title condition) @@ -118,7 +120,7 @@ describe('AppSheet', () => { describe('handle', () => { it('shows handle by default', () => { const { toJSON } = render( - + , ); // The handle container is always rendered by default (showHandle=true) @@ -129,11 +131,21 @@ describe('AppSheet', () => { it('hides handle when showHandle is false', () => { const withHandle = render( - + , ); const withoutHandle = render( - + , ); // The tree without handle should be smaller (no handleContainer view) @@ -151,7 +163,7 @@ describe('AppSheet', () => { const { getByText } = render( Custom Child Content - + , ); expect(getByText('Custom Child Content')).toBeTruthy(); @@ -162,7 +174,7 @@ describe('AppSheet', () => { First Child Second Child - + , ); expect(getByText('First Child')).toBeTruthy(); @@ -177,13 +189,9 @@ describe('AppSheet', () => { it('pressing close button triggers dismiss animation', async () => { const onClose = jest.fn(); const { getByText } = render( - + Content - + , ); const doneButton = getByText('Done'); @@ -195,7 +203,7 @@ describe('AppSheet', () => { () => { expect(onClose).toHaveBeenCalled(); }, - { timeout: 2000 } + { timeout: 2000 }, ); }); }); @@ -211,7 +219,7 @@ describe('AppSheet', () => { visible={true} snapPoints={['30%', '60%']} title="Snap Sheet" - /> + />, ); expect(toJSON()).toBeTruthy(); @@ -224,7 +232,7 @@ describe('AppSheet', () => { visible={true} snapPoints={[200, 400]} title="Numeric Snap" - /> + />, ); expect(toJSON()).toBeTruthy(); @@ -237,7 +245,7 @@ describe('AppSheet', () => { visible={true} enableDynamicSizing={true} title="Dynamic Sheet" - /> + />, ); expect(toJSON()).toBeTruthy(); @@ -245,11 +253,7 @@ describe('AppSheet', () => { it('renders without snap points (default 50%)', () => { const { toJSON } = render( - + , ); expect(toJSON()).toBeTruthy(); @@ -262,14 +266,19 @@ describe('AppSheet', () => { describe('elevation', () => { it('uses level3 elevation by default', () => { const { toJSON } = render( - + , ); expect(toJSON()).toBeTruthy(); }); it('accepts level4 elevation', () => { const { toJSON } = render( - + , ); expect(toJSON()).toBeTruthy(); }); @@ -289,7 +298,9 @@ describe('AppSheet', () => { mockAddListener = jest.spyOn(Keyboard, 'addListener').mockReturnValue({ remove: mockRemove, } as any); - mockDismiss = jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => { }); + mockDismiss = jest + .spyOn(Keyboard, 'dismiss') + .mockImplementation(() => {}); mockIsVisible = jest.spyOn(Keyboard, 'isVisible' as any); }); @@ -305,7 +316,7 @@ describe('AppSheet', () => { const { toJSON } = render( Content - + , ); expect(Keyboard.dismiss).not.toHaveBeenCalled(); @@ -324,7 +335,7 @@ describe('AppSheet', () => { const { toJSON } = render( Content - + , ); // Initially not visible @@ -334,7 +345,7 @@ describe('AppSheet', () => { render( Content - + , ); expect(Keyboard.dismiss).toHaveBeenCalled(); @@ -355,14 +366,14 @@ describe('AppSheet', () => { const { rerender, getByText } = render( Content - + , ); // Open the sheet — keyboard is visible, so modal deferred rerender( Content - + , ); expect(Keyboard.dismiss).toHaveBeenCalled(); @@ -384,13 +395,13 @@ describe('AppSheet', () => { const { rerender, getByText } = render( Content - + , ); rerender( Content - + , ); expect(Keyboard.dismiss).toHaveBeenCalled(); @@ -418,13 +429,13 @@ describe('AppSheet', () => { const { rerender } = render( Content - + , ); rerender( Content - + , ); // Fire the keyboard hide callback @@ -448,7 +459,7 @@ describe('AppSheet', () => { const { unmount } = render( Content - + , ); expect(Keyboard.addListener).toHaveBeenCalled(); @@ -525,7 +536,7 @@ describe('AppSheet', () => { const { rerender, toJSON } = render( Content - + , ); // Should be visible @@ -535,14 +546,17 @@ describe('AppSheet', () => { rerender( Content - + , ); // Wait for animation to complete - await waitFor(() => { - // After animation, the component may render null or a modal - expect(true).toBe(true); - }, { timeout: 1000 }); + await waitFor( + () => { + // After animation, the component may render null or a modal + expect(true).toBe(true); + }, + { timeout: 1000 }, + ); }); it('backdrop tap triggers dismiss', async () => { @@ -550,7 +564,7 @@ describe('AppSheet', () => { const { UNSAFE_getByType } = render( Content - + , ); const backdrop = UNSAFE_getByType(TouchableWithoutFeedback); @@ -569,7 +583,7 @@ describe('AppSheet', () => { const { UNSAFE_getByType } = render( Content - + , ); const modal = UNSAFE_getByType(Modal); @@ -584,6 +598,63 @@ describe('AppSheet', () => { { timeout: 2000 }, ); }); + + it('blocks backdrop, header, and system dismissal when not dismissible', () => { + const onClose = jest.fn(); + const { UNSAFE_getByType, getByTestId } = render( + + Content + , + ); + + fireEvent.press(UNSAFE_getByType(TouchableWithoutFeedback)); + fireEvent.press(getByTestId('app-sheet-close')); + act(() => { + UNSAFE_getByType(Modal).props.onRequestClose(); + }); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('uses the latest committed close callback from an existing handler', async () => { + const firstClose = jest.fn(); + const latestClose = jest.fn(); + const firstClosed = jest.fn(); + const latestClosed = jest.fn(); + const ui = render( + + Content + , + ); + const backdrop = ui.UNSAFE_getByType(TouchableWithoutFeedback); + + ui.rerender( + + Content + , + ); + fireEvent.press(backdrop); + + await waitFor(() => expect(latestClose).toHaveBeenCalledTimes(1)); + expect(latestClosed).toHaveBeenCalledTimes(1); + expect(firstClose).not.toHaveBeenCalled(); + expect(firstClosed).not.toHaveBeenCalled(); + }); }); // ============================================================================ @@ -598,7 +669,7 @@ describe('AppSheet', () => { visible={true} snapPoints={['invalid-snap']} title="Fallback Snap" - /> + />, ); expect(toJSON()).toBeTruthy(); @@ -613,7 +684,7 @@ describe('AppSheet', () => { const { UNSAFE_getByType } = render( Content - + , ); const modal = UNSAFE_getByType(Modal); @@ -654,7 +725,11 @@ describe('AppSheet', () => { * PanResponder accumulates dy via: dy += currentPageY - previousPageY. * Pass previousY to control the delta: dy_delta = pageY - previousY. */ - function makeTouchEvent(pageY: number, previousY?: number, timestamp = Date.now()) { + function makeTouchEvent( + pageY: number, + previousY?: number, + timestamp = Date.now(), + ) { const prevY = previousY ?? pageY; const touchEntry = { touchActive: true, @@ -670,8 +745,26 @@ describe('AppSheet', () => { }; return { nativeEvent: { - touches: [{ pageX: 0, pageY, identifier: 0, locationX: 0, locationY: pageY, timestamp }], - changedTouches: [{ pageX: 0, pageY, identifier: 0, locationX: 0, locationY: pageY, timestamp }], + touches: [ + { + pageX: 0, + pageY, + identifier: 0, + locationX: 0, + locationY: pageY, + timestamp, + }, + ], + changedTouches: [ + { + pageX: 0, + pageY, + identifier: 0, + locationX: 0, + locationY: pageY, + timestamp, + }, + ], target: 1, timestamp, }, @@ -688,7 +781,7 @@ describe('AppSheet', () => { const { UNSAFE_getAllByType } = render( Content - + , ); const handle = getHandleContainer(UNSAFE_getAllByType); @@ -697,7 +790,9 @@ describe('AppSheet', () => { // The onStartShouldSetResponder handler is the PanResponder wrapper around // onStartShouldSetPanResponder. Calling it exercises line 168. act(() => { - const result = handle.props.onStartShouldSetResponder?.(makeTouchEvent(100)); + const result = handle.props.onStartShouldSetResponder?.( + makeTouchEvent(100), + ); // Our config returns false, so the responder should not claim the gesture expect(result).toBe(false); }); @@ -707,7 +802,7 @@ describe('AppSheet', () => { const { UNSAFE_getAllByType } = render( Content - + , ); const handle = getHandleContainer(UNSAFE_getAllByType); @@ -715,7 +810,9 @@ describe('AppSheet', () => { act(() => { // Calling onMoveShouldSetResponder exercises the onMoveShouldSetPanResponder callback - const result = handle.props.onMoveShouldSetResponder?.(makeTouchEvent(115)); + const result = handle.props.onMoveShouldSetResponder?.( + makeTouchEvent(115), + ); if (result !== undefined) { expect(typeof result).toBe('boolean'); } @@ -726,7 +823,7 @@ describe('AppSheet', () => { const { UNSAFE_getAllByType } = render( Content - + , ); const handle = getHandleContainer(UNSAFE_getAllByType); @@ -746,7 +843,7 @@ describe('AppSheet', () => { const { UNSAFE_getAllByType } = render( Content - + , ); const handle = getHandleContainer(UNSAFE_getAllByType); @@ -768,16 +865,20 @@ describe('AppSheet', () => { // This lets us exercise lines 189-192 (the dismiss completion: setModalVisible + // onClose) without depending on the native animation driver in jest. const { Animated: RNAnimated } = require('react-native'); - const startMock = jest.fn((cb?: ((result: { finished: boolean }) => void)) => { - if (cb) cb({ finished: true }); - }); - jest.spyOn(RNAnimated, 'parallel').mockReturnValue({ start: startMock } as any); + const startMock = jest.fn( + (cb?: (result: { finished: boolean }) => void) => { + if (cb) cb({ finished: true }); + }, + ); + jest + .spyOn(RNAnimated, 'parallel') + .mockReturnValue({ start: startMock } as any); const onClose = jest.fn(); const { UNSAFE_getAllByType } = render( Content - + , ); const handle = getHandleContainer(UNSAFE_getAllByType); @@ -796,6 +897,32 @@ describe('AppSheet', () => { jest.restoreAllMocks(); }); + + it('uses the latest committed dismissible value for an existing swipe handle', () => { + const onClose = jest.fn(); + const ui = render( + + Content + , + ); + const handle = getHandleContainer(ui.UNSAFE_getAllByType); + expect(handle).toBeTruthy(); + + ui.rerender( + + Content + , + ); + act(() => { + expect( + handle.props.onMoveShouldSetResponder?.(makeTouchEvent(200, 0)), + ).toBe(false); + handle.props.onResponderMove?.(makeTouchEvent(200, 0)); + handle.props.onResponderRelease?.(makeTouchEvent(200, 200)); + }); + + expect(onClose).not.toHaveBeenCalled(); + }); }); // ============================================================================ @@ -807,18 +934,22 @@ describe('AppSheet', () => { // This simulates the sheet mid-animation where backdropEnabled=false. const { Animated: RNAnimated } = require('react-native'); const startMock = jest.fn(); // callback deliberately NOT called - jest.spyOn(RNAnimated, 'parallel').mockReturnValue({ start: startMock } as any); + jest + .spyOn(RNAnimated, 'parallel') + .mockReturnValue({ start: startMock } as any); const onClose = jest.fn(); const { UNSAFE_getByType } = render( Content - + , ); // Trigger animateIn (sets backdropEnabled=false, callback never fires) const modal = UNSAFE_getByType(Modal); - act(() => { modal.props.onShow(); }); + act(() => { + modal.props.onShow(); + }); // Backdrop press while animation is still running — must be ignored const backdrop = UNSAFE_getByType(TouchableWithoutFeedback); @@ -832,29 +963,38 @@ describe('AppSheet', () => { it('backdrop press works once animateIn completes (backdropEnabled=true)', async () => { // Fire the .start() callback synchronously so backdropEnabled becomes true. const { Animated: RNAnimated } = require('react-native'); - const startMock = jest.fn((cb?: (result: { finished: boolean }) => void) => { - cb?.({ finished: true }); - }); - jest.spyOn(RNAnimated, 'parallel').mockReturnValue({ start: startMock } as any); + const startMock = jest.fn( + (cb?: (result: { finished: boolean }) => void) => { + cb?.({ finished: true }); + }, + ); + jest + .spyOn(RNAnimated, 'parallel') + .mockReturnValue({ start: startMock } as any); const onClose = jest.fn(); const { UNSAFE_getByType } = render( Content - + , ); // Trigger animateIn — callback fires synchronously → backdropEnabled=true const modal = UNSAFE_getByType(Modal); - act(() => { modal.props.onShow(); }); + act(() => { + modal.props.onShow(); + }); // Backdrop press after animation completes — must dismiss const backdrop = UNSAFE_getByType(TouchableWithoutFeedback); fireEvent.press(backdrop); - await waitFor(() => { - expect(onClose).toHaveBeenCalled(); - }, { timeout: 2000 }); + await waitFor( + () => { + expect(onClose).toHaveBeenCalled(); + }, + { timeout: 2000 }, + ); jest.restoreAllMocks(); }); @@ -863,25 +1003,31 @@ describe('AppSheet', () => { // Allow animateIn to complete, then verify animateOut disables backdrop. const { Animated: RNAnimated } = require('react-native'); let callCount = 0; - const startMock = jest.fn((cb?: (result: { finished: boolean }) => void) => { - callCount++; - if (callCount === 1) { - // First call is animateIn — fire immediately so backdropEnabled=true - cb?.({ finished: true }); - } - // Second call is animateOut — do NOT fire, simulating mid-dismiss state - }); - jest.spyOn(RNAnimated, 'parallel').mockReturnValue({ start: startMock } as any); + const startMock = jest.fn( + (cb?: (result: { finished: boolean }) => void) => { + callCount++; + if (callCount === 1) { + // First call is animateIn — fire immediately so backdropEnabled=true + cb?.({ finished: true }); + } + // Second call is animateOut — do NOT fire, simulating mid-dismiss state + }, + ); + jest + .spyOn(RNAnimated, 'parallel') + .mockReturnValue({ start: startMock } as any); const onClose = jest.fn(); const { UNSAFE_getByType } = render( Content - + , ); const modal = UNSAFE_getByType(Modal); - act(() => { modal.props.onShow(); }); // animateIn completes → backdropEnabled=true + act(() => { + modal.props.onShow(); + }); // animateIn completes → backdropEnabled=true const backdrop = UNSAFE_getByType(TouchableWithoutFeedback); @@ -911,22 +1057,24 @@ describe('AppSheet', () => { const { UNSAFE_getByType } = render( Content - + , ); const modal = UNSAFE_getByType(Modal); - act(() => { modal.props.onShow(); }); + act(() => { + modal.props.onShow(); + }); // animateIn should use timing (for guaranteed callback) not spring expect(timingSpy).toHaveBeenCalled(); // The translateY call should have toValue: 0 (slide in) const slideInCall = timingSpy.mock.calls.find( - ([, config]: any[]) => config?.toValue === 0 + ([, config]: any[]) => config?.toValue === 0, ); expect(slideInCall).toBeTruthy(); // Spring should NOT be used for the entry animation const springToZero = springSpy.mock.calls.find( - ([, config]: any[]) => config?.toValue === 0 + ([, config]: any[]) => config?.toValue === 0, ); expect(springToZero).toBeFalsy(); diff --git a/__tests__/rntl/components/ChatInput.test.tsx b/__tests__/rntl/components/ChatInput.test.tsx index f51d1e3b3..55a0fd848 100644 --- a/__tests__/rntl/components/ChatInput.test.tsx +++ b/__tests__/rntl/components/ChatInput.test.tsx @@ -53,6 +53,7 @@ jest.mock('../../../src/services/documentService', () => ({ // Mock the stores const mockUseWhisperStore = jest.fn(); const mockUseAppStore = jest.fn(); +const mockRemoteServerState = { servers: [], activeServerId: null }; const mockUseUiModeStore = jest.fn((selector?: (s: { interfaceMode: string }) => unknown) => { const state = { interfaceMode: 'chat' }; return selector ? selector(state) : state; @@ -65,9 +66,13 @@ jest.mock('../../../src/stores', () => { // mocked store needs getState too (mirrors the hook return). const useAppStore = () => mockUseAppStore(); useAppStore.getState = () => mockUseAppStore(); + const useRemoteServerStore = (selector?: (state: typeof mockRemoteServerState) => unknown) => + selector ? selector(mockRemoteServerState) : mockRemoteServerState; + useRemoteServerStore.getState = () => mockRemoteServerState; return { useWhisperStore: () => mockUseWhisperStore(), useAppStore, + useRemoteServerStore, useUiModeStore, }; }); diff --git a/__tests__/rntl/components/ChatInputModeToggle.test.tsx b/__tests__/rntl/components/ChatInputModeToggle.test.tsx index 902121b87..9b81627f2 100644 --- a/__tests__/rntl/components/ChatInputModeToggle.test.tsx +++ b/__tests__/rntl/components/ChatInputModeToggle.test.tsx @@ -1,11 +1,10 @@ /** * ChatInputModeToggle tests * - * The pro-only Chat→Voice interface toggle in the chat-input pill row. It's a chip - * that opens a dropdown; choosing "Voice": + * The pro-only Text/Voice interface control is one direct icon toggle: * - when the voice model is NOT downloaded → routes to the Models Voice tab * - when downloaded → flips interfaceMode inline (chat→audio) - * - when the chip is disabled → does nothing (menu never opens) + * - when the control is disabled → does nothing */ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; @@ -22,15 +21,12 @@ jest.mock('@offgrid/core/utils/haptics', () => ({ import { ChatInputModeToggle } from '../../../pro/audio/ui/ChatInputModeToggle'; import { useTTSStore } from '../../../pro/audio/ttsStore'; -// The chip opens its dropdown via chipRef.measureInWindow(...) → setOpen(true). -// Host instances don't implement it under jest, so shim it to fire the callback. -beforeAll(() => { - (require('react-native').View.prototype as any).measureInWindow = (cb: (x: number, y: number, w: number, h: number) => void) => cb(0, 0, 100, 40); -}); - // isReady drives the `downloaded` gate (modelDownloaded ?? isReady) the component uses. -const setDownloaded = (downloaded: boolean, mode: 'chat' | 'audio' = 'chat') => { - useTTSStore.setState((s) => ({ +const setDownloaded = ( + downloaded: boolean, + mode: 'chat' | 'audio' = 'chat', +) => { + useTTSStore.setState(s => ({ isReady: downloaded, settings: { ...s.settings, interfaceMode: mode }, })); @@ -54,7 +50,6 @@ describe('ChatInputModeToggle', () => { const { getByTestId, getByText } = render(); fireEvent.press(getByTestId('chat-mode-toggle')); - fireEvent.press(getByTestId('mode-option-audio')); // No silent switch — a prompt appears and the mode stays on chat. expect(mockNavigate).not.toHaveBeenCalled(); @@ -62,7 +57,10 @@ describe('ChatInputModeToggle', () => { // Tapping "Get voice model" routes to the nested Models Voice tab. fireEvent.press(getByText('Get voice model')); - expect(mockNavigate).toHaveBeenCalledWith('Main', { screen: 'ModelsTab', params: { initialTab: 'voice' } }); + expect(mockNavigate).toHaveBeenCalledWith('Main', { + screen: 'ModelsTab', + params: { initialTab: 'voice' }, + }); expect(useTTSStore.getState().settings.interfaceMode).toBe('chat'); } finally { Platform.OS = prevOS; @@ -74,19 +72,17 @@ describe('ChatInputModeToggle', () => { const { getByTestId } = render(); fireEvent.press(getByTestId('chat-mode-toggle')); - fireEvent.press(getByTestId('mode-option-audio')); expect(mockNavigate).not.toHaveBeenCalled(); expect(useTTSStore.getState().settings.interfaceMode).toBe('audio'); }); - it('does not open the menu when disabled', () => { + it('does not change mode when disabled', () => { setDownloaded(true, 'chat'); - const { getByTestId, queryByTestId } = render(); + const { getByTestId } = render(); fireEvent.press(getByTestId('chat-mode-toggle')); - expect(queryByTestId('mode-option-audio')).toBeNull(); expect(useTTSStore.getState().settings.interfaceMode).toBe('chat'); }); }); diff --git a/__tests__/rntl/components/GenerationSettingsModal.test.tsx b/__tests__/rntl/components/GenerationSettingsModal.test.tsx index cdf16e514..2d2726c88 100644 --- a/__tests__/rntl/components/GenerationSettingsModal.test.tsx +++ b/__tests__/rntl/components/GenerationSettingsModal.test.tsx @@ -625,7 +625,7 @@ describe('GenerationSettingsModal', () => { }); it('displays formatted values for text settings', () => { - const { getByText, getByTestId } = render( + const { getByText, getAllByText, getByTestId } = render( , ); @@ -636,7 +636,8 @@ describe('GenerationSettingsModal', () => { expect(getByText('1.0K')).toBeTruthy(); // maxTokens: 1024 expect(getByText('0.90')).toBeTruthy(); // topP expect(getByText('1.10')).toBeTruthy(); // repeatPenalty - expect(getByText('4K')).toBeTruthy(); // contextLength: 4096 + // getAllByText: maxTokens and contextLength both default to 4096 and both format to "4K". + expect(getAllByText('4K').length).toBeGreaterThan(0); // contextLength: 4096 }); it('shows description for text settings', () => { diff --git a/__tests__/rntl/components/MarkdownText.test.tsx b/__tests__/rntl/components/MarkdownText.test.tsx index e7483d648..96e9c80b2 100644 --- a/__tests__/rntl/components/MarkdownText.test.tsx +++ b/__tests__/rntl/components/MarkdownText.test.tsx @@ -10,10 +10,15 @@ */ import React from 'react'; -import { render } from '@testing-library/react-native'; +import { fireEvent, render } from '@testing-library/react-native'; +import { Linking } from 'react-native'; import { MarkdownText, preprocessMarkdown } from '../../../src/components/MarkdownText'; describe('MarkdownText', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('renders plain text', () => { const { getByText } = render(Hello world); expect(getByText(/Hello world/)).toBeTruthy(); @@ -112,6 +117,29 @@ describe('MarkdownText', () => { const { toJSON } = render({longUrl}); expect(toJSON()).toBeTruthy(); }); + + it('opens a Markdown link and refuses an unsafe destination', () => { + const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); + const rendered = render( + {'[Docs](https://example.com/docs) [Unsafe](ftp://example.com/file)'}, + ); + + fireEvent.press(rendered.getByText('Docs')); + fireEvent.press(rendered.getByText('Unsafe')); + expect(openURL).toHaveBeenCalledTimes(1); + expect(openURL).toHaveBeenCalledWith('https://example.com/docs'); + expect(rendered.getAllByRole('link')).toHaveLength(2); + }); + + it('turns a plain web address into a safe clickable link', () => { + const openURL = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined); + const rendered = render( + {'Main page: https://github.com/off-grid-ai'}, + ); + + fireEvent.press(rendered.getByRole('link')); + expect(openURL).toHaveBeenCalledWith('https://github.com/off-grid-ai'); + }); }); describe('preprocessMarkdown', () => { diff --git a/__tests__/rntl/components/McpAddServerSheet.test.tsx b/__tests__/rntl/components/McpAddServerSheet.test.tsx index de0d59295..ce29aca23 100644 --- a/__tests__/rntl/components/McpAddServerSheet.test.tsx +++ b/__tests__/rntl/components/McpAddServerSheet.test.tsx @@ -78,6 +78,13 @@ maybe('McpAddServerSheet', () => { expect(props.onAddCustom).toHaveBeenCalledTimes(1); }); + it('has no "Scan a desktop QR" button - a paired desktop grants tools over the mesh', () => { + // The QR-scan pairing was removed: a paired desktop now hands its tools over + // the sync mesh, so there is nothing to scan from the add sheet. + const { queryByTestId } = render(); + expect(queryByTestId('scan-desktop-qr')).toBeNull(); + }); + it('lists the preset rows', () => { const props = baseProps(); const { getByTestId } = render(); diff --git a/__tests__/rntl/components/McpServersScreen.test.tsx b/__tests__/rntl/components/McpServersScreen.test.tsx index e936b0a30..7252655b5 100644 --- a/__tests__/rntl/components/McpServersScreen.test.tsx +++ b/__tests__/rntl/components/McpServersScreen.test.tsx @@ -45,7 +45,7 @@ jest.mock('@react-navigation/native', () => { jest.mock('../../../src/services/tools/extensions', () => ({ getToolExtensions: () => [] })); const mockAppState = { settings: { enabledTools: [] as string[] }, updateSettings: jest.fn(), activeModelId: undefined, downloadedModels: [] as any[] }; -const mockRemoteState = { activeRemoteTextModelId: 'remote-1' }; +const mockRemoteState = { activeRemoteTextModelId: 'remote-1', servers: [] as any[] }; jest.mock('../../../src/stores', () => ({ useAppStore: (selector?: any) => (selector ? selector(mockAppState) : mockAppState), useRemoteServerStore: (selector?: any) => (selector ? selector(mockRemoteState) : mockRemoteState), @@ -55,6 +55,10 @@ jest.mock('../../../pro/mcp/mcpService', () => ({ connectServer: jest.fn(), disconnectServer: jest.fn(), signOutServer: jest.fn(), })); +// The paired-desktops tools section pulls in the sync store + grant service (→ syncService, +// which does not load under jest). This suite is about the MCP server cards, so stub it out. +jest.mock('../../../pro/ui/CompanionToolsSection', () => ({ CompanionToolsSection: () => null })); + type ScreenModule = typeof import('../../../pro/ui/McpServersScreen'); type StoreModule = typeof import('../../../pro/mcp/mcpStore'); diff --git a/__tests__/rntl/components/ModelCard.test.tsx b/__tests__/rntl/components/ModelCard.test.tsx index 62ee0aaca..75d8dc3bf 100644 --- a/__tests__/rntl/components/ModelCard.test.tsx +++ b/__tests__/rntl/components/ModelCard.test.tsx @@ -77,10 +77,26 @@ describe('ModelCard', () => { /> ); // Both the size caption and the percent render (full-width bar + left/right row). - expect(getByText('2.0 GB / 4.0 GB')).toBeTruthy(); + expect(getByText('2.0 GB / 4.0 GB · Rate unavailable')).toBeTruthy(); expect(getByText('50%')).toBeTruthy(); }); + it('shows the canonical live rate while downloading', () => { + const { getByText } = render( + + ); + expect(getByText('2.0 GB / 4.0 GB · 2.5 MB/s')).toBeTruthy(); + }); + it('shows bytes alongside the Queued label (queued reads "0 B / size")', () => { const { getByText, getByLabelText } = render( { downloadCount={2} /> ); - expect(getByText('1.5 GB / 10.0 GB · 2 downloads')).toBeTruthy(); + expect(getByText('1.5 GB / 10.0 GB · Rate unavailable · 2 downloads')).toBeTruthy(); }); it('omits the "N downloads" note for a single download', () => { @@ -332,7 +348,7 @@ describe('ModelCard', () => { downloadCount={1} /> ); - expect(getByText('2.0 GB / 4.0 GB')).toBeTruthy(); + expect(getByText('2.0 GB / 4.0 GB · Rate unavailable')).toBeTruthy(); expect(queryByText(/downloads/)).toBeNull(); }); @@ -1045,14 +1061,16 @@ describe('ModelCard', () => { expect(getByText('50%')).toBeTruthy(); }); - it('shows 0% when totalBytes is 0 (unknown size)', () => { - const { getByText } = render( + it('does not invent a percentage when the failed download size is unknown', () => { + const { getByText, queryByText } = render( , ); - expect(getByText('0%')).toBeTruthy(); + expect(getByText('Stopped')).toBeTruthy(); + expect(queryByText(/NaN/)).toBeNull(); + expect(queryByText('0%')).toBeNull(); }); it('hides ModelCardActions when failedState is set', () => { diff --git a/__tests__/rntl/components/PlaybackControls.test.tsx b/__tests__/rntl/components/PlaybackControls.test.tsx index 85b1bd080..7d9bed78b 100644 --- a/__tests__/rntl/components/PlaybackControls.test.tsx +++ b/__tests__/rntl/components/PlaybackControls.test.tsx @@ -7,7 +7,7 @@ * resumed ("play not clickable") — the tap reached nothing. */ import React from 'react'; -import { TouchableOpacity } from 'react-native'; +import { StyleSheet, TouchableOpacity } from 'react-native'; import { render, fireEvent, renderHook } from '@testing-library/react-native'; // Render the icon as a Text carrying its Feather name, so tests can assert which glyph @@ -22,7 +22,11 @@ import { PlayButton, usePlaybackState } from '../../../pro/audio/ui/AudioMessage import { useTTSStore } from '../../../pro/audio/ttsStore'; const colors = { primary: '#0f0' } as any; -const styles = { playButton: {}, playButtonDisabled: {} } as any; +const styles = { + playButton: {}, + playButtonDisabled: {}, + playButtonLoader: { paddingHorizontal: 4 }, +} as any; function renderButton(props: Partial>) { const onPlayPause = jest.fn(); @@ -75,8 +79,11 @@ describe('PlayButton — touchability (always controllable when this is the acti }); it('renders a spinner (non-touchable) while THIS is preparing but not yet playing/synth', () => { - const { UNSAFE_queryAllByType } = renderButton({ isThisLoading: true }); + const { UNSAFE_queryAllByType, getByTestId } = renderButton({ isThisLoading: true }); expect(UNSAFE_queryAllByType(TouchableOpacity)).toHaveLength(0); + expect(StyleSheet.flatten(getByTestId('voice-note-play-loader').props.style)).toEqual( + expect.objectContaining({ paddingHorizontal: 4 }), + ); }); it('is touchable in the normal idle state', () => { diff --git a/__tests__/rntl/components/ProAhaSheet.test.tsx b/__tests__/rntl/components/ProAhaSheet.test.tsx new file mode 100644 index 000000000..d897c8785 --- /dev/null +++ b/__tests__/rntl/components/ProAhaSheet.test.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { ProAhaSheet } from '../../../src/components/ProAhaSheet'; + +describe('ProAhaSheet remote media value', () => { + it('shows the work a named Desktop can do for this phone', () => { + const ui = render( + undefined} onRegister={() => undefined} />, + ); + + expect(ui.getByText('Create images with the model active on your Desktop')).toBeTruthy(); + expect(ui.getByText('Transcribe speech with the model active on your Desktop')).toBeTruthy(); + expect(ui.getByText('Hear replies in the voice active on your Desktop')).toBeTruthy(); + expect(ui.getByText('Control which models your named Desktop serves')).toBeTruthy(); + }); +}); diff --git a/__tests__/rntl/components/SharePromptSheet.test.tsx b/__tests__/rntl/components/SharePromptSheet.test.tsx index 7a1cef1c8..da69c0d64 100644 --- a/__tests__/rntl/components/SharePromptSheet.test.tsx +++ b/__tests__/rntl/components/SharePromptSheet.test.tsx @@ -13,6 +13,9 @@ import { useAppStore } from '../../../src/stores/appStore'; import { GITHUB_URL } from '../../../src/utils/sharePrompt'; jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined as any); + +// Either store's label is right - which one renders depends on the platform the suite runs as. +const RATE_LABEL = /^Rate on (the App Store|Google Play)$/; jest.spyOn(Linking, 'canOpenURL').mockResolvedValue(false); function renderSheet(onClose = jest.fn()) { @@ -30,7 +33,7 @@ describe('SharePromptSheet', () => { const { getByText } = renderSheet(); expect(getByText(/Off Grid AI is completely free/)).toBeTruthy(); expect(getByText('Star on GitHub')).toBeTruthy(); - expect(getByText('Share on X')).toBeTruthy(); + expect(getByText(RATE_LABEL)).toBeTruthy(); expect(getByText('Maybe later')).toBeTruthy(); expect(getByText("Don't show again")).toBeTruthy(); }); @@ -43,15 +46,17 @@ describe('SharePromptSheet', () => { expect(useAppStore.getState().hasEngagedSharePrompt).toBe(true); }); - it('shares to X, marks engaged, and closes on Share press', async () => { + it('opens this platform store to rate, marks engaged, and closes on press', async () => { const { getByText, onClose } = renderSheet(); - fireEvent.press(getByText('Share on X')); - // Engagement + close are synchronous; the X open resolves on the next tick - // (uses the x.com/intent/post web intent). + fireEvent.press(getByText(RATE_LABEL)); + // Engagement + close are synchronous; opening the store resolves on the next tick. expect(onClose).toHaveBeenCalled(); expect(useAppStore.getState().hasEngagedSharePrompt).toBe(true); await waitFor(() => { - expect(Linking.openURL).toHaveBeenCalledWith(expect.stringMatching(/^https:\/\/x\.com\/intent\/post/)); + // Whichever store this platform has - never the other one's. + expect(Linking.openURL).toHaveBeenCalledWith( + expect.stringMatching(/^(https:\/\/apps\.apple\.com\/app\/id|market:\/\/details|https:\/\/play\.google\.com\/store)/), + ); }); }); diff --git a/__tests__/rntl/components/VoiceModelsPanel.test.tsx b/__tests__/rntl/components/VoiceModelsPanel.test.tsx index b09ddc6fa..96e7ad69e 100644 --- a/__tests__/rntl/components/VoiceModelsPanel.test.tsx +++ b/__tests__/rntl/components/VoiceModelsPanel.test.tsx @@ -60,9 +60,11 @@ const actions = { clearError: jest.fn(), }; let mockStoreState: any; -jest.mock('../../../pro/audio/ttsStore', () => ({ useTTSStore: () => mockStoreState })); +jest.mock('../../../pro/audio/ttsStore', () => ({ + useTTSStore: (selector?: (state: any) => unknown) => + selector ? selector(mockStoreState) : mockStoreState, +})); -import { useFocusEffect } from '@react-navigation/native'; import { VoiceModelsPanel } from '../../../pro/audio/ui/VoiceModelsPanel'; const VOICES = [ @@ -95,12 +97,13 @@ describe('VoiceModelsPanel', () => { expect(getByText(/nothing is sent anywhere/)).toBeTruthy(); }); - it('lists voices when the model is downloaded and selects one on tap', async () => { + it('filters voices by language and selects the first voice when language changes', async () => { const { getByTestId } = await renderPanel(); expect(getByTestId('voice-af_heart')).toBeTruthy(); - expect(getByTestId('voice-bf_emma')).toBeTruthy(); + expect(() => getByTestId('voice-bf_emma')).toThrow(); - await act(async () => { fireEvent.press(getByTestId('voice-bf_emma')); }); + fireEvent.press(getByTestId('models-tts-language')); + await act(async () => { fireEvent.press(getByTestId('models-tts-language-en-GB')); }); expect(actions.setVoice).toHaveBeenCalledWith('bf_emma'); }); @@ -131,8 +134,21 @@ describe('VoiceModelsPanel', () => { it('shows live progress while the service reports downloading', async () => { mockDownloads = [ttsDl('downloading', 0.4)]; mockStoreState.isReady = false; - const { getByText } = await renderPanel(); + const { getByText, queryByText } = await renderPanel(); expect(getByText('40%')).toBeTruthy(); + expect(getByText('Rate unavailable')).toBeTruthy(); + expect(queryByText(/NaN/)).toBeNull(); + }); + + it('shows bytes and rate when the engine reports them', async () => { + mockDownloads = [ttsDl('downloading', 0.5)]; + mockStoreState.isReady = false; + mockStoreState.downloadCurrentBytes = 25 * 1024 * 1024; + mockStoreState.downloadTotalBytes = 50 * 1024 * 1024; + mockStoreState.downloadBytesPerSecond = 2 * 1024 * 1024; + const { getByText } = await renderPanel(); + expect(getByText('50%')).toBeTruthy(); + expect(getByText('25 MB / 50 MB · 2.0 MB/s')).toBeTruthy(); }); it('shows progress (not the idle CTA) for queued and paused too — the shared in-progress predicate', async () => { @@ -147,12 +163,9 @@ describe('VoiceModelsPanel', () => { } }); - it('backfills the persisted-downloaded flag from disk on focus', async () => { - let focusCb: (() => void) | undefined; - (useFocusEffect as jest.Mock).mockImplementation((cb: () => void) => { focusCb = cb; }); + it('backfills the persisted-downloaded flag from disk when the panel opens', async () => { + actions.checkDownloadStatus.mockClear(); await renderPanel(); - actions.checkDownloadStatus.mockClear(); // drop the mount-effect call - await act(async () => { focusCb?.(); await Promise.resolve(); }); expect(actions.checkDownloadStatus).toHaveBeenCalled(); }); }); diff --git a/__tests__/rntl/components/VoiceRecordButton.test.tsx b/__tests__/rntl/components/VoiceRecordButton.test.tsx index 9885bcd15..d53ca9412 100644 --- a/__tests__/rntl/components/VoiceRecordButton.test.tsx +++ b/__tests__/rntl/components/VoiceRecordButton.test.tsx @@ -344,8 +344,8 @@ describe('VoiceRecordButton', () => { expect(toJSON()).toBeTruthy(); }); - it('shows mic icon in loading state when asSendButton', () => { - const { toJSON } = render( + it('shows loading progress in loading state when asSendButton', () => { + const { getByLabelText, getByTestId } = render( { /> ); - const treeStr = JSON.stringify(toJSON()); - // asSendButton + loading shows mic icon - expect(treeStr).toContain('mic'); + expect(getByTestId('voice-loading')).toBeTruthy(); + expect(getByLabelText('Working')).toBeTruthy(); }); it('shows transcribing state without text when asSendButton and transcribing', () => { @@ -372,8 +371,8 @@ describe('VoiceRecordButton', () => { expect(toJSON()).toBeTruthy(); }); - it('shows mic icon in transcribing state when asSendButton', () => { - const { toJSON } = render( + it('shows loading progress in transcribing state when asSendButton', () => { + const { getByLabelText } = render( { /> ); - const treeStr = JSON.stringify(toJSON()); - // asSendButton + transcribing shows mic icon - expect(treeStr).toContain('mic'); + expect(getByLabelText('Working')).toBeTruthy(); }); }); diff --git a/__tests__/rntl/components/companionToolsSection.test.tsx b/__tests__/rntl/components/companionToolsSection.test.tsx new file mode 100644 index 000000000..6a083d199 --- /dev/null +++ b/__tests__/rntl/components/companionToolsSection.test.tsx @@ -0,0 +1,84 @@ +/** + * Integration (RNTL): CompanionToolsSection - the single home for desktop tools. + * + * Proves the moved grant: paired desktops (only desktops) appear in Pro tools with a + * switch that reflects whether their tools are connected here (grantedByDeviceId), and + * flipping one calls requestTools(deviceId, next) - the same mesh request the old + * Devices-row toggle sent. Loaded via a computed path so it skips where pro/ is absent. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; + +jest.mock('react-native-vector-icons/Feather', () => { + const { Text } = require('react-native'); + return ({ name, ...props }: any) => {name}; +}); + +jest.mock('../../../src/theme', () => ({ + useTheme: () => ({ + colors: { + text: '#000', textMuted: '#999', primary: '#1DB954', surface: '#F5F5F5', border: '#E0E0E0', + }, + }), +})); + +const mockState: { knownDevices: unknown[]; servers: unknown[] } = { knownDevices: [], servers: [] }; +const mockRequestTools = jest.fn(); + +jest.mock('../../../pro/sync/syncStore', () => ({ + useSyncStore: (selector: (s: unknown) => unknown) => + selector({ knownDevices: mockState.knownDevices }), +})); +jest.mock('../../../pro/mcp/mcpStore', () => ({ + useMcpStore: (selector: (s: unknown) => unknown) => selector({ servers: mockState.servers }), +})); +jest.mock('../../../pro/mcp/mcpToolGrantService', () => ({ + requestTools: (...args: unknown[]) => mockRequestTools(...args), +})); + +type Mod = typeof import('../../../pro/ui/CompanionToolsSection'); +function load(): Mod | null { + try { + return require(['..', '..', '..', 'pro', 'ui', 'CompanionToolsSection'].join('/')); + } catch { + return null; + } +} + +const mod = load(); +const maybe = mod ? describe : describe.skip; + +maybe('CompanionToolsSection', () => { + const { CompanionToolsSection } = mod!; + + beforeEach(() => { + mockState.knownDevices = []; + mockState.servers = []; + mockRequestTools.mockClear(); + }); + + it('lists only desktop peers, reflects the grant, and toggles via requestTools', () => { + mockState.knownDevices = [ + { id: 'mac1', name: 'My Mac', platform: 'macos' }, + { id: 'phone1', name: 'My Phone', platform: 'ios' }, + ]; + mockState.servers = [{ id: 's1', grantedByDeviceId: 'mac1' }]; + + const { getByTestId, queryByTestId } = render(); + // Desktop shows; a phone peer (serves no tools) is filtered out. + expect(getByTestId('companion-tools-mac1')).toBeTruthy(); + expect(queryByTestId('companion-tools-phone1')).toBeNull(); + + const sw = getByTestId('companion-tools-switch-mac1'); + expect(sw.props.value).toBe(true); // granted -> on + fireEvent(sw, 'valueChange', false); + expect(mockRequestTools).toHaveBeenCalledWith('mac1', false); + }); + + it('renders nothing when there are no paired desktops', () => { + mockState.knownDevices = [{ id: 'phone1', name: 'Phone', platform: 'android' }]; + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); +}); diff --git a/__tests__/rntl/components/pairingCodeSheet.test.tsx b/__tests__/rntl/components/pairingCodeSheet.test.tsx new file mode 100644 index 000000000..1ce4dbcf9 --- /dev/null +++ b/__tests__/rntl/components/pairingCodeSheet.test.tsx @@ -0,0 +1,120 @@ +/** + * Integration (RNTL): PairingCodeSheet scan-to-pair. + * + * Guards the approved behavior change: a paired-code sheet can be filled by scanning + * the other device's QR, not just by typing. A decoded QR carrying a valid pairing + * code lands on the SAME onPair (syncService.pair) as the typed path, and a QR that + * is not a pairing code is ignored so the scanner keeps looking. + * + * Lives in the private pro/ submodule, loaded via a computed path so the suite skips + * in open-core CI where pro/ is absent. + */ + +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react-native'; + +jest.mock('react-native-vector-icons/Feather', () => { + const { Text } = require('react-native'); + return ({ name, ...props }: any) => {name}; +}); + +// The sheet is a modal wrapper; render its children inline (respecting `visible`, as +// the real one does) so the test can drive the content and observe it hiding while +// the scanner is open. +jest.mock('@offgrid/core/components/AppSheet', () => ({ + AppSheet: ({ visible, children }: { visible: boolean; children: React.ReactNode }) => + visible ? children : null, +})); + +jest.mock('../../../src/theme', () => { + const colors = { + text: '#000', textMuted: '#999', primary: '#1DB954', error: '#F00', + background: '#FFF', surface: '#F5F5F5', border: '#E0E0E0', + }; + const shadows = { small: {}, medium: {}, large: {} }; + return { + useTheme: () => ({ colors, shadows, isDark: false }), + useThemedStyles: (fn: any) => fn(colors, shadows), + }; +}); + +// vision-camera is globally stubbed in jest.setup; capture the scan config here so +// the test can simulate a decoded QR frame. +const visionCamera = require('react-native-vision-camera'); +let scanConfig: { onCodeScanned: (codes: { value?: string }[]) => void } | null = null; + +type SheetModule = typeof import('../../../pro/ui/SyncScreen/PairingCodeSheet'); + +function load(): SheetModule | null { + try { + return require(['..', '..', '..', 'pro', 'ui', 'SyncScreen', 'PairingCodeSheet'].join('/')); + } catch { + return null; + } +} + +const mod = load(); +const maybe = mod ? describe : describe.skip; + +// A valid code: every character is in the pairing alphabet. +const VALID_QR = 'ABCD2345'; + +maybe('PairingCodeSheet scan-to-pair', () => { + const { PairingCodeSheet } = mod!; + + const baseProps = () => ({ + visible: true, + deviceName: 'Studio Mac', + confirmLabel: 'Pair', + testIDPrefix: 'sync-test', + onClose: jest.fn(), + onPair: jest.fn().mockResolvedValue(undefined), + }); + + beforeEach(() => { + scanConfig = null; + jest.spyOn(visionCamera, 'useCodeScanner').mockImplementation((cfg: any) => { + scanConfig = cfg; + return cfg; + }); + }); + + it('offers a Scan button that opens the camera scanner', () => { + const { getByTestId, queryByText, getByText } = render( + , + ); + expect(queryByText('Camera access needed')).toBeNull(); + fireEvent.press(getByTestId('sync-test-scan')); + // Global vision-camera mock reports no permission, so the scanner asks for it - + // proof the scanner surface mounted. + expect(getByText('Camera access needed')).toBeTruthy(); + }); + + it('hides the pairing sheet while the scanner is open (one modal at a time)', () => { + // iOS presents one modal at a time; the sheet must yield so the scanner can show. + const { getByTestId, queryByTestId } = render(); + expect(queryByTestId('sync-test-input')).toBeTruthy(); + fireEvent.press(getByTestId('sync-test-scan')); + expect(queryByTestId('sync-test-input')).toBeNull(); + }); + + it('pairs from a scanned QR via the same onPair as typing', async () => { + const props = baseProps(); + const { getByTestId } = render(); + fireEvent.press(getByTestId('sync-test-scan')); + await act(async () => { + scanConfig!.onCodeScanned([{ value: VALID_QR }]); + }); + expect(props.onPair).toHaveBeenCalledWith(VALID_QR); + }); + + it('ignores a QR that is not a pairing code', async () => { + const props = baseProps(); + const { getByTestId } = render(); + fireEvent.press(getByTestId('sync-test-scan')); + await act(async () => { + scanConfig!.onCodeScanned([{ value: 'https://example.com/not-a-code' }]); + }); + expect(props.onPair).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/rntl/screens/DownloadManagerScreen.test.tsx b/__tests__/rntl/screens/DownloadManagerScreen.test.tsx index 8e2af66e6..dd330340d 100644 --- a/__tests__/rntl/screens/DownloadManagerScreen.test.tsx +++ b/__tests__/rntl/screens/DownloadManagerScreen.test.tsx @@ -546,8 +546,31 @@ describe('DownloadManagerScreen', () => { }; const { getByText } = render(); - // Progress bar is shown but no status text for running downloads - expect(getByText('256 B / 1 KB')).toBeTruthy(); + expect(getByText('25% · 256 B / 1 KB · Rate unavailable')).toBeTruthy(); + }); + + it('shows the measured rate for an active download', () => { + mockDownloadStoreDownloads = { + 'author/model-id/active-model.gguf': { + modelKey: 'author/model-id/active-model.gguf', + downloadId: 'dl-rate', + modelId: 'author/model-id', + fileName: 'active-model.gguf', + quantization: 'Q4_K_M', + modelType: 'text', + status: 'running', + bytesDownloaded: 512 * 1024, + totalBytes: 1024 * 1024, + combinedTotalBytes: 1024 * 1024, + bytesPerSecond: 128 * 1024, + progress: 0.5, + createdAt: Date.now(), + lastProgressAt: Date.now(), + }, + }; + + const { getByText } = render(); + expect(getByText('50% · 512 KB / 1 MB · 128.0 KB/s')).toBeTruthy(); }); it('does not show storage section when no completed models', () => { diff --git a/__tests__/rntl/screens/HomeScreen.test.tsx b/__tests__/rntl/screens/HomeScreen.test.tsx index 3bc2073e0..f3a97f93e 100644 --- a/__tests__/rntl/screens/HomeScreen.test.tsx +++ b/__tests__/rntl/screens/HomeScreen.test.tsx @@ -22,6 +22,7 @@ import { render, fireEvent, act, waitFor } from '@testing-library/react-native'; import { NavigationContainer } from '@react-navigation/native'; import { useAppStore } from '../../../src/stores/appStore'; import { useChatStore } from '../../../src/stores/chatStore'; +import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; import { resetStores, createMultipleConversations } from '../../utils/testHelpers'; import { createDownloadedModel, @@ -34,6 +35,7 @@ import { import { Linking, Clipboard } from 'react-native'; import { OFF_GRID_DESKTOP_URL } from '../../../src/constants'; import { withUtm } from '../../../src/utils/utm'; +import * as networkDiscovery from '../../../src/services/networkDiscovery'; // Mock requestAnimationFrame (globalThis as any).requestAnimationFrame = (cb: () => void) => { @@ -322,6 +324,66 @@ describe('HomeScreen', () => { } }); + describe('LAN discovery lifecycle', () => { + it('waits for saved remote settings and recovers from a pre-scan remount', async () => { + jest.useFakeTimers(); + const discoverySpy = jest.spyOn(networkDiscovery, 'discoverLANServers').mockResolvedValue([]); + let finishHydration: ((state: ReturnType) => void) | undefined; + const unsubscribe = jest.fn(); + const persistApi = useRemoteServerStore.persist; + const hydratedSpy = jest.spyOn(persistApi, 'hasHydrated').mockReturnValue(false); + const hydrationSpy = jest.spyOn(persistApi, 'onFinishHydration').mockImplementation(callback => { + finishHydration = callback; + return unsubscribe; + }); + useAppStore.getState().updateSettings({ autoDiscoverRemoteModels: undefined }); + let firstMount: ReturnType | undefined; + let secondMount: ReturnType | undefined; + + try { + firstMount = renderHomeScreen(); + await act(async () => undefined); + await act(async () => { + jest.advanceTimersByTime(3000); + await Promise.resolve(); + }); + expect(discoverySpy).not.toHaveBeenCalled(); + + useRemoteServerStore.setState({ + servers: [{ id: 'saved', name: 'Saved gateway', endpoint: 'http://saved.test', providerType: 'ollama' }], + } as any); + await act(async () => { finishHydration?.(useRemoteServerStore.getState()); }); + expect(useAppStore.getState().settings.autoDiscoverRemoteModels).toBe(true); + + // Unmount after hydration but before the deferred scan. A later mount + // must be able to schedule the scan again from the hydrated store. + await act(async () => { + jest.advanceTimersByTime(1000); + await Promise.resolve(); + }); + firstMount.unmount(); + firstMount = undefined; + expect(discoverySpy).not.toHaveBeenCalled(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + + hydratedSpy.mockReturnValue(true); + secondMount = renderHomeScreen(); + await act(async () => { + jest.advanceTimersByTime(3000); + await Promise.resolve(); + }); + expect(discoverySpy).toHaveBeenCalledTimes(1); + } finally { + firstMount?.unmount(); + secondMount?.unmount(); + hydrationSpy.mockRestore(); + hydratedSpy.mockRestore(); + discoverySpy.mockRestore(); + jest.useRealTimers(); + } + }); + }); + // ============================================================================ // Off Grid AI Desktop promo card // ============================================================================ @@ -607,6 +669,27 @@ describe('HomeScreen', () => { expect(getByText(/Hi there, how can I help/)).toBeTruthy(); }); + it('shows a clean enhanced-prompt preview without model protocol', () => { + const conv = createConversation({ + title: 'Draw a dog', + messages: [ + createMessage({ role: 'user', content: 'Draw a dog' }), + createMessage({ + role: 'assistant', + content: + '__LABEL:Enhanced prompt__\nA sleek black dog in soft morning light.', + }), + ], + }); + useChatStore.setState({ conversations: [conv] }); + + const { getByText, queryByText } = renderHomeScreen(); + expect( + getByText('A sleek black dog in soft morning light.'), + ).toBeTruthy(); + expect(queryByText(/|__LABEL:/)).toBeNull(); + }); + it('shows "You: " prefix for last user message', () => { const conv = createConversation({ title: 'User Preview Test', diff --git a/__tests__/rntl/screens/ModelDownloadHelpers.test.tsx b/__tests__/rntl/screens/ModelDownloadHelpers.test.tsx index 82f9bd0dc..4590b6810 100644 --- a/__tests__/rntl/screens/ModelDownloadHelpers.test.tsx +++ b/__tests__/rntl/screens/ModelDownloadHelpers.test.tsx @@ -18,10 +18,16 @@ jest.mock('../../../src/components', () => ({ }, })); -jest.mock('../../../src/services', () => ({ +// Hugging Face is the remote boundary. ModelDownloadHelpers now reaches it through +// modelCatalogFiles, so both import paths must resolve to the same controlled adapter. +jest.mock('../../../src/services/huggingface', () => ({ huggingFaceService: { getModelFiles: jest.fn() }, })); +jest.mock('../../../src/services', () => + jest.requireMock('../../../src/services/huggingface'), +); + jest.mock('../../../src/utils/logger', () => ({ __esModule: true, default: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, diff --git a/__tests__/rntl/screens/ModelDownloadScreen.test.tsx b/__tests__/rntl/screens/ModelDownloadScreen.test.tsx index 85058ddbe..a87557f4d 100644 --- a/__tests__/rntl/screens/ModelDownloadScreen.test.tsx +++ b/__tests__/rntl/screens/ModelDownloadScreen.test.tsx @@ -78,6 +78,18 @@ const mockGetModelFiles = jest.fn, any[]>(() => Promise.resolve([ const mockDownloadModel = jest.fn(); const mockDownloadModelBackground = jest.fn(); +jest.mock('../../../src/services/modelCatalogFiles', () => ({ + fetchModelFiles: async (models: { id: string }[]) => { + const result: Record = {}; + for (const model of models) { + const files = await mockGetModelFiles(model.id); + const file = files.find((candidate: any) => candidate.quantization.toUpperCase() === 'Q4_K_M'); + if (file) result[model.id] = [file]; + } + return result; + }, +})); + jest.mock('../../../src/services', () => ({ hardwareService: { getDeviceInfo: jest.fn(() => Promise.resolve({ deviceModel: 'Test Device', availableMemory: 8000000000 })), @@ -163,14 +175,6 @@ jest.mock('../../../src/components/Button', () => ({ }, })); -jest.mock('../../../src/components/RemoteServerModal', () => ({ - RemoteServerModal: ({ visible }: any) => { - if (!visible) return null; - const { View, Text } = require('react-native'); - return Add Remote Server; - }, -})); - jest.mock('../../../src/components/AnimatedEntry', () => ({ AnimatedEntry: ({ children }: any) => children, })); @@ -225,7 +229,7 @@ jest.mock('../../../src/screens/ModelDownloadHelpers', () => { import { Platform } from 'react-native'; import { useDownloadStore } from '../../../src/stores/downloadStore'; -import { ModelDownloadScreen } from '../../../src/screens/ModelDownloadScreen'; +import { AdvancedSetupScreen } from '../../../src/screens/ModelDownloadScreen'; import { LITERT_PARENT_ID } from '../../../src/services/curatedLiteRTRegistry'; const MOCK_FILE = { @@ -277,14 +281,14 @@ describe('ModelDownloadScreen', () => { // =========================================================================== it('renders the loading state initially', () => { const { getByText } = render( - , + , ); expect(getByText(/Analyzing your device/)).toBeTruthy(); }); it('renders with testID for loading state', () => { const { getByTestId } = render( - , + , ); expect(getByTestId('model-download-loading')).toBeTruthy(); }); @@ -292,19 +296,19 @@ describe('ModelDownloadScreen', () => { // =========================================================================== // Loaded state // =========================================================================== - it('renders the loaded state with "Set Up Your AI" title', async () => { + it('renders the loaded Advanced Setup state', async () => { mockGetModelFiles.mockResolvedValue([MOCK_FILE]); - const result = render(); + const result = render(); await flushPromises(); expect(result.getByTestId('model-download-screen')).toBeTruthy(); - expect(result.getByText('Set Up Your AI')).toBeTruthy(); + expect(result.getByText('Advanced Setup')).toBeTruthy(); expect(result.getByText(/Connect to a model server/)).toBeTruthy(); }); it('renders device info card after loading', async () => { - const result = render(); + const result = render(); await flushPromises(); expect(result.getByText('Your Device')).toBeTruthy(); @@ -315,7 +319,7 @@ describe('ModelDownloadScreen', () => { }); it('renders the NetworkSection', async () => { - const result = render(); + const result = render(); await flushPromises(); expect(result.getByTestId('network-section')).toBeTruthy(); @@ -323,7 +327,7 @@ describe('ModelDownloadScreen', () => { }); it('renders "Download to Your Device" section title', async () => { - const result = render(); + const result = render(); await flushPromises(); expect(result.getByText('Download to Your Device')).toBeTruthy(); @@ -333,7 +337,7 @@ describe('ModelDownloadScreen', () => { // Skip button // =========================================================================== it('skip button navigates to Main', async () => { - const result = render(); + const result = render(); await flushPromises(); const skipButton = result.getByTestId('model-download-skip'); @@ -347,7 +351,7 @@ describe('ModelDownloadScreen', () => { it('renders recommended models based on device RAM', async () => { mockGetModelFiles.mockResolvedValue([MOCK_FILE]); - const result = render(); + const result = render(); await flushPromises(); expect(result.getByTestId('recommended-model-0')).toBeTruthy(); @@ -356,7 +360,7 @@ describe('ModelDownloadScreen', () => { it('shows warning card when no compatible models', async () => { mockHardwareService.getTotalMemoryGB.mockReturnValue(1); - const result = render(); + const result = render(); await flushPromises(); expect(result.getByText('Limited Compatibility')).toBeTruthy(); @@ -366,7 +370,7 @@ describe('ModelDownloadScreen', () => { mockGetModelFiles.mockResolvedValue([MOCK_FILE]); mockDownloadModelBackground.mockResolvedValue({ downloadId: 1 }); - const result = render(); + const result = render(); const downloadBtn = await result.findByTestId('recommended-model-0-download'); await act(async () => { @@ -381,7 +385,7 @@ describe('ModelDownloadScreen', () => { mockModelManager.isBackgroundDownloadSupported.mockReturnValue(true); mockDownloadModelBackground.mockResolvedValue({ downloadId: 123 }); - const result = render(); + const result = render(); await flushPromises(); const downloadBtn = await result.findByTestId('recommended-model-0-download', {}, { timeout: 5000 }); @@ -405,7 +409,7 @@ describe('ModelDownloadScreen', () => { mockModelManager.watchDownload.mockImplementation((_id: number, onComplete: any) => { capturedOnComplete = onComplete; }); - const result = render(); + const result = render(); await flushPromises(); const downloadBtn = result.getByTestId('recommended-model-0-download'); await act(async () => { fireEvent.press(downloadBtn); }); @@ -434,7 +438,7 @@ describe('ModelDownloadScreen', () => { capturedOnError = onError; }); - const result = render(); + const result = render(); await flushPromises(); const downloadBtn = result.getByTestId('recommended-model-0-download'); @@ -454,7 +458,7 @@ describe('ModelDownloadScreen', () => { mockDownloadModelBackground.mockRejectedValue(new Error('Unexpected error')); - const result = render(); + const result = render(); await flushPromises(); const downloadBtn = result.getByTestId('recommended-model-0-download'); @@ -468,7 +472,7 @@ describe('ModelDownloadScreen', () => { it('init error shows error alert', async () => { mockHardwareService.getDeviceInfo.mockRejectedValueOnce(new Error('Hardware error')); - render(); + render(); await act(async () => { await Promise.resolve(); @@ -494,7 +498,7 @@ describe('ModelDownloadScreen', () => { mockRemoteServerState.servers = [MOCK_SERVER]; mockRemoteServerState.discoveredModels = {}; - render(); + render(); await flushPromises(); await act(async () => { @@ -511,7 +515,7 @@ describe('ModelDownloadScreen', () => { mockRemoteServerState.servers = [MOCK_SERVER]; mockRemoteServerState.discoveredModels = {}; - render(); + render(); await flushPromises(); await act(async () => { @@ -526,7 +530,7 @@ describe('ModelDownloadScreen', () => { const { remoteServerManager: mockRsm } = jest.requireMock('../../../src/services'); mockRsm.testConnection.mockResolvedValueOnce({ success: false, error: 'Timeout' }); - render(); + render(); await flushPromises(); await act(async () => { @@ -544,7 +548,7 @@ describe('ModelDownloadScreen', () => { const { discoverLANServers } = jest.requireMock('../../../src/services/networkDiscovery'); discoverLANServers.mockRejectedValueOnce(new Error('wifi off')); - const result = render(); + const result = render(); await flushPromises(); const scanBtn = result.getByTestId('scan-network-btn'); @@ -563,7 +567,7 @@ describe('ModelDownloadScreen', () => { mockRemoteServerState.testConnection.mockResolvedValue({ success: false }); mockRemoteServerState.servers = []; - const result = render(); + const result = render(); await flushPromises(); const scanBtn = result.getByTestId('scan-network-btn'); @@ -591,7 +595,7 @@ describe('ModelDownloadScreen', () => { it('renders curated LiteRT cards on Android', async () => { Platform.OS = 'android'; - const result = render(); + const result = render(); await flushPromises(); expect(result.getByTestId('litert-model-0')).toBeTruthy(); @@ -600,7 +604,7 @@ describe('ModelDownloadScreen', () => { it('does NOT render LiteRT cards on iOS', async () => { Platform.OS = 'ios'; - const result = render(); + const result = render(); await flushPromises(); expect(result.queryByTestId('litert-model-0')).toBeNull(); @@ -617,7 +621,7 @@ describe('ModelDownloadScreen', () => { it('downloading a LiteRT model uses the curated parent id', async () => { Platform.OS = 'android'; mockDownloadModelBackground.mockResolvedValue({ downloadId: 7 }); - const result = render(); + const result = render(); await flushPromises(); await act(async () => { fireEvent.press(result.getByTestId('litert-model-0-download')); }); diff --git a/__tests__/rntl/screens/ModelSettingsScreen.test.tsx b/__tests__/rntl/screens/ModelSettingsScreen.test.tsx index 26738aabb..c299f1c96 100644 --- a/__tests__/rntl/screens/ModelSettingsScreen.test.tsx +++ b/__tests__/rntl/screens/ModelSettingsScreen.test.tsx @@ -437,9 +437,11 @@ describe('ModelSettingsScreen', () => { }); it('shows Context Length slider label and default value', () => { - const { getByText } = renderWithSections('text'); + const { getByText, getAllByText } = renderWithSections('text'); expect(getByText('Context Length')).toBeTruthy(); - expect(getByText('4K')).toBeTruthy(); // 4096 -> 4K + // getAllByText, because Max Tokens and Context Length BOTH default to 4096 and both format + // to "4K" - a single-match query asserted that coincidence rather than this slider's value. + expect(getAllByText('4K').length).toBeGreaterThan(0); // 4096 -> 4K }); it('shows context length description', () => { @@ -462,9 +464,10 @@ describe('ModelSettingsScreen', () => { }); it('shows Batch Size slider label and default value', () => { - const { getByText } = renderWithSections('text'); + const { getByText, getAllByText } = renderWithSections('text'); expect(getByText('Batch Size')).toBeTruthy(); - expect(getByText('512')).toBeTruthy(); + // Same ambiguity as Context Length: another control also renders 512 by default. + expect(getAllByText('512').length).toBeGreaterThan(0); }); }); diff --git a/__tests__/rntl/screens/OnboardingScreen.test.tsx b/__tests__/rntl/screens/OnboardingScreen.test.tsx index 0c47f5f0c..5e1d6f551 100644 --- a/__tests__/rntl/screens/OnboardingScreen.test.tsx +++ b/__tests__/rntl/screens/OnboardingScreen.test.tsx @@ -55,13 +55,10 @@ jest.mock('../../../src/components/Button', () => ({ const mockSetOnboardingComplete = jest.fn(); -// Mutable so individual tests can flip the auto-discovery gate (fresh installs are OFF by default). -let mockAutoDiscoverEnabled = false; jest.mock('../../../src/stores', () => { // Getters resolve lazily at access time so the `mock*` closures are defined by then. const state = { get setOnboardingComplete() { return mockSetOnboardingComplete; }, - get settings() { return { autoDiscoverRemoteModels: mockAutoDiscoverEnabled }; }, }; const useAppStore: any = jest.fn((selector?: any) => (selector ? selector(state) : state)); useAppStore.getState = () => state; @@ -88,30 +85,6 @@ jest.mock('../../../src/constants', () => ({ ], })); -const mockDiscoverLANServers = jest.fn().mockResolvedValue([]); -jest.mock('../../../src/services/networkDiscovery', () => ({ - discoverLANServers: (...args: any[]) => mockDiscoverLANServers(...args), -})); - -const mockAddServer = jest.fn().mockResolvedValue({ id: 'new-server' }); -jest.mock('../../../src/services', () => ({ - remoteServerManager: { - addServer: (...args: any[]) => mockAddServer(...args), - }, -})); - -jest.mock('../../../src/stores/remoteServerStore', () => ({ - useRemoteServerStore: Object.assign( - jest.fn((selector?: any) => { - const state = { servers: [] }; - return selector ? selector(state) : state; - }), - { - getState: jest.fn(() => ({ servers: [] })), - }, - ), -})); - import { OnboardingScreen } from '../../../src/screens/OnboardingScreen'; import { WEDNESDAY_URL } from '../../../src/constants'; @@ -127,7 +100,6 @@ const navigation = { describe('OnboardingScreen', () => { beforeEach(() => { jest.clearAllMocks(); - mockAutoDiscoverEnabled = false; // fresh install default — auto-discovery OFF }); it('renders first slide content', () => { @@ -166,7 +138,7 @@ describe('OnboardingScreen', () => { fireEvent.press(getByText('Skip')); expect(mockSetOnboardingComplete).toHaveBeenCalledWith(true); - expect(mockReplace).toHaveBeenCalledWith('ModelDownload'); + expect(mockReplace).toHaveBeenCalledWith('AutoSetup'); }); it('does not complete onboarding when Next is pressed on non-last slide', () => { @@ -227,86 +199,6 @@ describe('OnboardingScreen', () => { expect(getByTestId('onboarding-next')).toBeTruthy(); }); - it('does NOT scan on mount when auto-discovery is off (fresh-install default)', async () => { - const { act: reactAct } = require('@testing-library/react-native'); - mockAutoDiscoverEnabled = false; - mockDiscoverLANServers.mockResolvedValue([ - { endpoint: 'http://192.168.1.10:11434', type: 'ollama', name: 'Ollama' }, - ]); - - render(); - await reactAct(async () => { await Promise.resolve(); await Promise.resolve(); }); - - // Gated OFF by default — the fresh-install onboarding must not scan the network unprompted. - expect(mockDiscoverLANServers).not.toHaveBeenCalled(); - expect(mockAddServer).not.toHaveBeenCalled(); - }); - - it('kicks off LAN discovery on mount when auto-discovery is enabled', async () => { - const { act: reactAct } = require('@testing-library/react-native'); - mockAutoDiscoverEnabled = true; - mockDiscoverLANServers.mockResolvedValue([ - { - endpoint: 'http://192.168.1.10:11434', - type: 'ollama', - name: 'Ollama (192.168.1.10)', - }, - ]); - - render(); - - await reactAct(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(mockDiscoverLANServers).toHaveBeenCalled(); - expect(mockAddServer).toHaveBeenCalledWith({ - name: 'Ollama (192.168.1.10)', - endpoint: 'http://192.168.1.10:11434', - providerType: 'openai-compatible', - }); - }); - - it('does not add duplicate servers during LAN discovery', async () => { - const { act: reactAct } = require('@testing-library/react-native'); - mockAutoDiscoverEnabled = true; - const { - useRemoteServerStore, - } = require('../../../src/stores/remoteServerStore'); - useRemoteServerStore.getState.mockReturnValue({ - servers: [{ endpoint: 'http://192.168.1.10:11434' }], - }); - mockDiscoverLANServers.mockResolvedValue([ - { endpoint: 'http://192.168.1.10:11434', type: 'ollama', name: 'Ollama' }, - ]); - - render(); - - await reactAct(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(mockAddServer).not.toHaveBeenCalled(); - }); - - it('handles LAN discovery errors gracefully', async () => { - const { act: reactAct } = require('@testing-library/react-native'); - mockAutoDiscoverEnabled = true; - mockDiscoverLANServers.mockRejectedValue(new Error('Network error')); - - render(); - - await reactAct(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); - - // Should not throw — error is caught - expect(mockDiscoverLANServers).toHaveBeenCalled(); - }); - it('opens correct Wednesday URL when tapping Made with love', () => { const { Linking } = require('react-native'); const spy = jest @@ -344,6 +236,6 @@ describe('OnboardingScreen', () => { fireEvent.press(getByTestId('onboarding-next')); expect(mockSetOnboardingComplete).toHaveBeenCalledWith(true); - expect(mockReplace).toHaveBeenCalledWith('ModelDownload'); + expect(mockReplace).toHaveBeenCalledWith('AutoSetup'); }); }); diff --git a/__tests__/rntl/screens/ProDetailScreen.test.tsx b/__tests__/rntl/screens/ProDetailScreen.test.tsx index dd2ed6e01..e74a47e5f 100644 --- a/__tests__/rntl/screens/ProDetailScreen.test.tsx +++ b/__tests__/rntl/screens/ProDetailScreen.test.tsx @@ -37,6 +37,7 @@ jest.mock('../../../src/services/proLicenseService', () => ({ })); import { ProDetailScreen } from '../../../src/screens/ProDetailScreen'; +import { ProUnlockModal } from '../../../src/screens/ProDetailScreen/ProUnlockModal'; /** * PARTIALLY GREEN, and the four that remain red are red for one reason: this suite mocks @@ -76,8 +77,9 @@ describe('ProDetailScreen', () => { }); it('renders the Get Pro call-to-action when the user is not Pro', () => { - const { queryAllByText } = render(); + const { queryAllByText, queryByText } = render(); expect(queryAllByText('Get Pro').length).toBeGreaterThan(0); + expect(queryByText('Use Pro from another device')).toBeNull(); }); it('Get Pro opens the web pay page directly without a modal', () => { @@ -98,6 +100,16 @@ describe('ProDetailScreen', () => { ); }); + it('shows the remote media outcomes and named Desktop control', () => { + const { getByText } = render(); + expect(getByText('Your Desktop does the heavy work')).toBeTruthy(); + expect( + getByText( + 'Create images, transcribe speech, and hear replies with the models active on your named Desktop. You choose which models it serves.', + ), + ).toBeTruthy(); + }); + it('shows the Off Grid AI Desktop link to Pro-active users too', async () => { useAppStore.setState({ hasRegisteredPro: true }); const { getByText } = render(); @@ -133,6 +145,38 @@ describe('ProDetailScreen', () => { await waitFor(() => expect(getByText('Pro activated')).toBeTruthy()); }); + it('keeps an in-flight activation and its result visible if the parent closes', async () => { + let finishActivation!: (result: { ok: true }) => void; + mockActivateProByKey.mockImplementationOnce( + () => + new Promise(resolve => { + finishActivation = resolve; + }), + ); + const onClose = jest.fn(); + const onUnlocked = jest.fn(); + const ui = render( + , + ); + fireEvent.changeText(ui.getByTestId('license-key-input'), 'key/abc123'); + fireEvent.press(ui.getByTestId('unlock-cta')); + expect(ui.getByText('Activating...')).toBeTruthy(); + + ui.rerender( + , + ); + expect(ui.getByText('Activating...')).toBeTruthy(); + + finishActivation({ ok: true }); + await waitFor(() => expect(ui.getByText('Pro activated')).toBeTruthy()); + expect(onUnlocked).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + }); + it('lets the user dismiss the success card with Got it', async () => { mockActivateProByKey.mockResolvedValueOnce({ ok: true }); const { getByText, getByTestId, queryByText } = render(); diff --git a/__tests__/rntl/screens/ToolsScreen.test.tsx b/__tests__/rntl/screens/ToolsScreen.test.tsx index f2680c2ef..757d0858b 100644 --- a/__tests__/rntl/screens/ToolsScreen.test.tsx +++ b/__tests__/rntl/screens/ToolsScreen.test.tsx @@ -15,6 +15,7 @@ import { ToolsScreen } from '../../../src/screens/ToolsScreen'; import { AVAILABLE_TOOLS } from '../../../src/services/tools/registry'; import { registerScreen, _clearScreensForTesting } from '../../../src/navigation/screenRegistry'; import { PRO_TOOLS_SCREEN } from '../../../src/hooks/useIsProActive'; +import { useAppStore } from '../../../src/stores/appStore'; const mockNavigate = jest.fn(); const mockGoBack = jest.fn(); @@ -69,6 +70,12 @@ describe('ToolsScreen', () => { jest.clearAllMocks(); _clearScreensForTesting(); mockEnabledTools = ['web_search', 'calculator']; + useAppStore.setState({ + hasRegisteredPro: false, + hasSavedProCredential: false, + isProActive: false, + proDeviceAdmission: 'unknown', + }); }); afterEach(() => { _clearScreensForTesting(); @@ -95,6 +102,12 @@ describe('ToolsScreen', () => { }); it('routes a pro user straight to the Pro Tools screen', () => { + useAppStore.setState({ + hasRegisteredPro: true, + hasSavedProCredential: true, + isProActive: true, + proDeviceAdmission: 'active', + }); registerScreen({ name: PRO_TOOLS_SCREEN, component: () => null }); const { getByTestId } = render(); fireEvent.press(getByTestId('tools-pro-tools')); diff --git a/__tests__/services/autoSetupCatalog.test.ts b/__tests__/services/autoSetupCatalog.test.ts new file mode 100644 index 000000000..4dca7ca4c --- /dev/null +++ b/__tests__/services/autoSetupCatalog.test.ts @@ -0,0 +1,31 @@ +import { RECOMMENDED_MODELS } from '../../src/constants'; +import { buildAutoSetupTextCandidates } from '../../src/services/autoSetupCatalog'; +import { selectAutoSetupPlans } from '../../src/services/autoSetupPlan'; +import { recommendedModelsForDevice } from '../../src/utils/recommendedModels'; + +const GB = 1024 ** 3; + +test('an iPhone 17 Pro Max gets 2B, 4B, and Qwen 3.5 9B setup choices', () => { + const files = Object.fromEntries(RECOMMENDED_MODELS.map(model => [model.id, [{ + name: `${model.params}B-Q4_K_M.gguf`, + size: model.params * 0.6 * GB, + quantization: 'Q4_K_M', + downloadUrl: `https://boundary.test/${model.id}`, + mmProjFile: { + name: 'mmproj-F16.gguf', + size: 0.8 * GB, + downloadUrl: `https://boundary.test/${model.id}/mmproj`, + }, + }]])); + + const catalog = buildAutoSetupTextCandidates(recommendedModelsForDevice(12), files, 12); + const image = [{ id: 'image', name: 'Image', kind: 'image' as const, sizeBytes: 1, fitScore: 0, payload: {} as never }]; + const stt = [{ id: 'stt', name: 'Speech', kind: 'stt' as const, sizeBytes: 1, fitScore: 0, payload: { modelId: 'stt' } }]; + const plans = selectAutoSetupPlans({ text: catalog, image, stt }); + + expect(plans.map(plan => plan.items[0].name)).toEqual([ + 'Gemma 4 E2B', + 'Gemma 4 E4B', + 'Qwen 3.5 9B', + ]); +}); diff --git a/__tests__/services/autoSetupPlan.test.ts b/__tests__/services/autoSetupPlan.test.ts new file mode 100644 index 000000000..4cdaaaa1b --- /dev/null +++ b/__tests__/services/autoSetupPlan.test.ts @@ -0,0 +1,125 @@ +import { + selectAutoSetupPlans, + type AutoSetupCompatibleCatalog, +} from '../../src/services/autoSetupPlan'; + +const candidate = ( + kind: 'text' | 'image' | 'stt', + name: string, + sizeBytes: number, + fitScore: number, + parameterCountB?: number, +) => ({ + id: `${kind}-${name}`, + name, + kind, + sizeBytes, + fitScore, + parameterCountB, + payload: + kind === 'text' + ? { + modelId: name, + file: { + name: `${name}.gguf`, + size: sizeBytes, + quantization: 'Q4_K_M', + downloadUrl: 'https://boundary.test/text', + }, + } + : kind === 'image' + ? { + id: `${kind}-${name}`, + name, + description: name, + size: sizeBytes, + downloadUrl: 'https://boundary.test/image', + style: 'general', + backend: 'mnn', + } + : { modelId: `${kind}-${name}` }, +}); + +test('Lean, Balanced, and Extreme select only from the compatible device catalog', () => { + const catalog = { + text: [ + candidate('text', 'small', 100, 4, 2), + candidate('text', 'fit', 200, 0, 4), + candidate('text', 'large', 300, 2, 9), + ], + image: [ + candidate('image', 'small', 10, 4), + candidate('image', 'fit', 20, 0), + candidate('image', 'large', 30, 2), + ], + stt: [ + candidate('stt', 'small', 1, 4), + candidate('stt', 'fit', 2, 0), + candidate('stt', 'large', 3, 2), + ], + } as AutoSetupCompatibleCatalog; + + const plans = selectAutoSetupPlans(catalog); + expect( + plans.map(plan => [plan.tier, ...plan.items.map(item => item.name)]), + ).toEqual([ + ['lean', 'small', 'small', 'small'], + ['balanced', 'fit', 'fit', 'fit'], + ['extreme', 'large', 'large', 'large'], + ]); + expect(plans[1].totalBytes).toBe(222); +}); + +test('text plans target 2B, 4B, and 9B instead of file-size order', () => { + const catalog = { + text: [ + candidate('text', 'Qwen 0.8B', 80, 0, 0.8), + candidate('text', 'Qwen 9B', 500, 3, 9), + candidate('text', 'Gemma 4B', 900, 2, 4), + candidate('text', 'Gemma 2B', 700, 1, 2), + ], + image: [candidate('image', 'image', 10, 0)], + stt: [candidate('stt', 'speech', 1, 0)], + } as AutoSetupCompatibleCatalog; + + const plans = selectAutoSetupPlans(catalog); + expect(plans.map(plan => plan.items[0].name)).toEqual([ + 'Gemma 2B', + 'Gemma 4B', + 'Qwen 9B', + ]); +}); + +test('no plan is forced when one compatible model kind is absent', () => { + const catalog = { + text: [candidate('text', 'fit', 2, 0)], + image: [], + stt: [candidate('stt', 'fit', 1, 0)], + } as AutoSetupCompatibleCatalog; + expect(selectAutoSetupPlans(catalog)).toEqual([]); +}); + +test('plan selection does not change candidate order', () => { + const catalog = { + text: [ + candidate('text', 'large', 300, 2, 9), + candidate('text', 'small', 100, 4, 2), + ], + image: [ + candidate('image', 'large', 30, 2), + candidate('image', 'small', 10, 4), + ], + stt: [candidate('stt', 'large', 3, 2), candidate('stt', 'small', 1, 4)], + } as AutoSetupCompatibleCatalog; + const originalOrder = { + text: catalog.text.map(item => item.id), + image: catalog.image.map(item => item.id), + stt: catalog.stt.map(item => item.id), + }; + + selectAutoSetupPlans(catalog); + + expect(catalog.text.map(item => item.id)).toEqual(originalOrder.text); + expect(catalog.image.map(item => item.id)).toEqual(originalOrder.image); + expect(catalog.stt.map(item => item.id)).toEqual(originalOrder.stt); +}); diff --git a/__tests__/unit/components/ensureWhisperForTranscription.test.ts b/__tests__/unit/components/ensureWhisperForTranscription.test.ts index 38e44db46..d8d30b730 100644 --- a/__tests__/unit/components/ensureWhisperForTranscription.test.ts +++ b/__tests__/unit/components/ensureWhisperForTranscription.test.ts @@ -11,7 +11,7 @@ const makeDeps = (over: Partial const freeGenerationModels = jest.fn(async () => {}); const loadWhisper = jest.fn(async () => 'loaded' as const); const deps = { - isLoaded: () => false, + isSelectedModelLoaded: () => false, hasDownloadedModel: () => true, loadWhisper, freeGenerationModels, @@ -22,7 +22,7 @@ const makeDeps = (over: Partial describe('ensureWhisperForTranscription', () => { it('returns true immediately when whisper is already loaded (no load, no eviction)', async () => { - const { deps, freeGenerationModels, loadWhisper } = makeDeps({ isLoaded: () => true }); + const { deps, freeGenerationModels, loadWhisper } = makeDeps({ isSelectedModelLoaded: () => true }); await expect(ensureWhisperForTranscription(deps)).resolves.toBe(true); expect(loadWhisper).not.toHaveBeenCalled(); expect(freeGenerationModels).not.toHaveBeenCalled(); diff --git a/__tests__/unit/engine/kokoroLiveState.test.ts b/__tests__/unit/engine/kokoroLiveState.test.ts index 8d992e9c2..093c1c44d 100644 --- a/__tests__/unit/engine/kokoroLiveState.test.ts +++ b/__tests__/unit/engine/kokoroLiveState.test.ts @@ -100,6 +100,7 @@ describe('KokoroEngine — live download lifecycle is the source of truth', () = ); const p = engine.downloadAssets(); + await Promise.resolve(); // the engine serializes asset fetches through one microtask queue // MID-FETCH: the stale flag was reset, phase is 'downloading' → NOT complete. // FAILS on the old early-return (stale _genuineCompletion faked progress=1 + done). diff --git a/__tests__/unit/engine/kokoroVoiceCatalog.test.ts b/__tests__/unit/engine/kokoroVoiceCatalog.test.ts new file mode 100644 index 000000000..b29e48733 --- /dev/null +++ b/__tests__/unit/engine/kokoroVoiceCatalog.test.ts @@ -0,0 +1,45 @@ +/** + * The real Mobile Kokoro catalog uses the shared customer-facing names and + * language metadata, so Desktop and Mobile do not rename the same voice. + */ +import { + getKokoroAssetSources, + getKokoroTTSVoices, +} from '../../../pro/audio/engine/tts/engines/kokoro/voices'; +import { models } from 'react-native-executorch'; + +describe('Kokoro voice catalog', () => { + it('uses shared voice names and language labels', () => { + const voices = getKokoroTTSVoices(); + + const runtimeVoiceCount = Object.values(models.text_to_speech.kokoro) + .reduce((count, language) => count + Object.keys(language).length, 0); + expect(voices).toHaveLength(runtimeVoiceCount); + + expect(voices.find(voice => voice.id === 'af_heart')).toMatchObject({ + label: 'Heart', + metadata: { language: 'English (US)' }, + }); + expect(voices.find(voice => voice.id === 'bf_emma')).toMatchObject({ + label: 'Emma', + metadata: { language: 'English (UK)' }, + }); + expect(voices.find(voice => voice.id === 'hf_alpha')).toMatchObject({ + label: 'Alpha', + metadata: { language: 'Hindi' }, + }); + expect(voices.find(voice => voice.id === 'df_anna')).toMatchObject({ + label: 'Anna', + metadata: { language: 'German' }, + }); + }); + + it('resolves a complete downloadable asset package for every voice', () => { + for (const voice of getKokoroTTSVoices()) { + const sources = getKokoroAssetSources(voice.id as Parameters[0]); + expect(sources.length).toBeGreaterThanOrEqual(3); + expect(sources.every(source => source.startsWith('https://'))).toBe(true); + expect(sources.some(source => source.includes(`/voices/${voice.id}.bin`))).toBe(true); + } + }); +}); diff --git a/__tests__/unit/hooks/useIsProActive.test.tsx b/__tests__/unit/hooks/useIsProActive.test.tsx index d5763eb87..de8343366 100644 --- a/__tests__/unit/hooks/useIsProActive.test.tsx +++ b/__tests__/unit/hooks/useIsProActive.test.tsx @@ -22,14 +22,18 @@ describe('useIsProActive / useHasRegisteredScreen', () => { // Registration alone no longer means Pro: the device must still be entitled, or a device the owner // removed from the licence would keep every Pro entry point until the app restarted. useAppStore.setState({ + hasRegisteredPro: true, hasSavedProCredential: true, + isProActive: true, proDeviceAdmission: 'active', }); }); afterEach(() => { _clearScreensForTesting(); useAppStore.setState({ + hasRegisteredPro: false, hasSavedProCredential: false, + isProActive: false, proDeviceAdmission: 'unknown', }); }); diff --git a/__tests__/unit/hooks/useOpenProTools.test.tsx b/__tests__/unit/hooks/useOpenProTools.test.tsx index 153c64d40..388371c6a 100644 --- a/__tests__/unit/hooks/useOpenProTools.test.tsx +++ b/__tests__/unit/hooks/useOpenProTools.test.tsx @@ -12,6 +12,7 @@ import { render, fireEvent } from '@testing-library/react-native'; import { useOpenProTools } from '../../../src/hooks/useOpenProTools'; import { registerScreen, _clearScreensForTesting } from '../../../src/navigation/screenRegistry'; import { PRO_TOOLS_SCREEN } from '../../../src/hooks/useIsProActive'; +import { useAppStore } from '../../../src/stores/appStore'; const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => { @@ -35,6 +36,12 @@ describe('useOpenProTools', () => { beforeEach(() => { jest.clearAllMocks(); _clearScreensForTesting(); + useAppStore.setState({ + hasRegisteredPro: false, + hasSavedProCredential: false, + isProActive: false, + proDeviceAdmission: 'unknown', + }); }); afterEach(() => { _clearScreensForTesting(); @@ -47,9 +54,30 @@ describe('useOpenProTools', () => { }); it('routes a pro user to the Pro Tools screen once it is registered', () => { + useAppStore.setState({ + hasRegisteredPro: true, + hasSavedProCredential: true, + isProActive: true, + proDeviceAdmission: 'active', + }); registerScreen({ name: PRO_TOOLS_SCREEN, component: () => null }); const { getByTestId } = render(); fireEvent.press(getByTestId('open')); expect(mockNavigate).toHaveBeenCalledWith(PRO_TOOLS_SCREEN); }); + + it('routes to the purchase screen as soon as live access is removed', () => { + registerScreen({ name: PRO_TOOLS_SCREEN, component: () => null }); + useAppStore.setState({ + hasRegisteredPro: false, + hasSavedProCredential: false, + isProActive: false, + proDeviceAdmission: 'unknown', + }); + + const { getByTestId } = render(); + fireEvent.press(getByTestId('open')); + + expect(mockNavigate).toHaveBeenCalledWith('ProDetail'); + }); }); diff --git a/__tests__/unit/licensing/proLicenseProvider.test.ts b/__tests__/unit/licensing/proLicenseProvider.test.ts index cd7ed69b9..3fed3c8a9 100644 --- a/__tests__/unit/licensing/proLicenseProvider.test.ts +++ b/__tests__/unit/licensing/proLicenseProvider.test.ts @@ -114,7 +114,11 @@ describe('the licence this phone holds', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); }); - afterEach(() => { + afterEach(async () => { + await load() + .proLicenseProvider.clearForTesting?.() + .catch(() => undefined); + jest.useRealTimers(); keygen.restore(); jest.restoreAllMocks(); }); @@ -243,18 +247,35 @@ describe('the licence this phone holds', () => { }); }); - it('waits for the mesh, and says so when it never arrives', async () => { + it('waits through delayed Sync startup for the registration owner', async () => { + jest.useFakeTimers(); + const provider = load(); + const mesh = activationOwner(); + + const activation = provider.proLicenseProvider.activate!(LICENCE_KEY); + setTimeout( + () => provider.setDirectEntitlementActivationOwner(mesh.owner), + 6_000, + ); + + await jest.advanceTimersByTimeAsync(6_000); + await expect(activation).resolves.toEqual({ ok: true }); + expect(mesh.calls).toEqual(['prepare', 'commit', 'finalize']); + }); + + it('reports local activation startup when the registration owner never arrives', async () => { + jest.useFakeTimers(); const provider = load(); // No activation owner registered: on a build where sync has not started, the seat cannot be claimed. This - // is the reason activation appears to hang and then fails rather than reporting a bad key. - await expect( - provider.proLicenseProvider.activate!(LICENCE_KEY), - ).resolves.toEqual({ + // is local startup state, not proof that the licence service could not be reached. + const activation = provider.proLicenseProvider.activate!(LICENCE_KEY); + await jest.advanceTimersByTimeAsync(30_000); + await expect(activation).resolves.toEqual({ ok: false, - reason: 'network_unavailable', + reason: 'activation_unavailable', }); - }, 10_000); + }); it('lets the mesh be swapped out and put back', async () => { const provider = load(); @@ -454,7 +475,7 @@ describe('the licence this phone holds', () => { }); }); - it('calls a licence with an expiry a yearly one, and shows the date', async () => { + it('shows a legacy timed licence as a neutral subscription', async () => { keygen.reset(); keygen.addLicence({ key: LICENCE_KEY, @@ -469,11 +490,31 @@ describe('the licence this phone holds', () => { provider.proLicenseProvider.getInfo(), ).resolves.toMatchObject({ isPro: true, - tier: 'yearly', + tier: 'subscription', expiry: '2030-01-01T00:00:00.000Z', }); }); + it('shows the new RevenueCat key as a monthly plan', async () => { + keygen.reset(); + keygen.addLicence({ + key: LICENCE_KEY, + seats: 3, + expiry: '2030-01-01T00:00:00.000Z', + metadata: { tier: 'monthly' }, + }); + const provider = load(); + provider.setDirectEntitlementActivationOwner(activationOwner().owner); + await provider.proLicenseProvider.activate!(LICENCE_KEY); + + await expect( + provider.proLicenseProvider.getInfo(), + ).resolves.toMatchObject({ + isPro: true, + tier: 'monthly', + }); + }); + it('survives a keychain that cannot be read at all', async () => { const provider = load(); keychain().getGenericPassword.mockRejectedValue( @@ -511,6 +552,35 @@ describe('the licence this phone holds', () => { ); }); + it('removes access at the exact cached expiry without a restart', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2030-01-01T00:00:00.000Z')); + keygen.reset(); + keygen.addLicence({ + key: LICENCE_KEY, + seats: 3, + expiry: '2030-01-01T00:00:05.000Z', + }); + const provider = load(); + provider.setDirectEntitlementActivationOwner(activationOwner().owner); + await provider.proLicenseProvider.activate!(LICENCE_KEY); + + await expect(provider.proLicenseProvider.readActive()).resolves.toBe( + true, + ); + await jest.advanceTimersByTimeAsync(5_001); + + await expect(provider.proLicenseProvider.readActive()).resolves.toBe( + false, + ); + await expect( + provider.proLicenseProvider.getInfo(), + ).resolves.toMatchObject({ + isPro: false, + credentialSaved: true, + }); + }); + it('locks the app when the licence has been revoked', async () => { const provider = await licensed(); // Refunded, charged back, or revoked by an admin: the provider stops recognising the key. @@ -537,6 +607,105 @@ describe('the licence this phone holds', () => { ); }); + it('closes a saved credential locally before a throttled foreground check', async () => { + const start = Date.UTC(2026, 7, 26, 9, 0, 0); + let now = start; + jest.spyOn(Date, 'now').mockImplementation(() => now); + keygen.reset(); + keygen.addLicence({ + key: LICENCE_KEY, + seats: 3, + expiry: new Date(start + 5_000).toISOString(), + }); + const provider = await licensed(); + await provider.proLicenseProvider.revalidate!('peer_connected'); + const providerCalls = keygen.calls.length; + + now = start + 5_000; + await provider.proLicenseProvider.revalidate!('foreground'); + + await expect( + provider.proLicenseProvider.getInfo(), + ).resolves.toMatchObject({ + isPro: false, + credentialSaved: true, + expired: true, + }); + expect(keygen.calls).toHaveLength(providerCalls); + }); + + it('reports an expired saved credential as inactive on cold start', async () => { + secrets.set( + 'off-grid-pro-license', + JSON.stringify({ + isPro: true, + key: LICENCE_KEY, + licenseId: 'licence-1', + expiry: new Date(Date.now() - 1).toISOString(), + verifiedAt: Date.now() - 10_000, + }), + ); + const provider = load(); + + await expect( + provider.proLicenseProvider.getInfo(), + ).resolves.toMatchObject({ + isPro: false, + credentialSaved: true, + expired: true, + }); + await expect(provider.proLicenseProvider.readActive()).resolves.toBe( + false, + ); + }); + + it('closes access at the exact saved deadline without a network event', async () => { + jest.useFakeTimers(); + const start = Date.UTC(2026, 7, 26, 9, 0, 0); + jest.setSystemTime(start); + keygen.reset(); + keygen.addLicence({ + key: LICENCE_KEY, + seats: 3, + expiry: new Date(start + 5_000).toISOString(), + }); + const provider = await licensed(); + const decisions: boolean[] = []; + provider.onProLicenseInfoChanged(info => decisions.push(info.isPro)); + const providerCalls = keygen.calls.length; + + await jest.advanceTimersByTimeAsync(5_000); + + await expect( + provider.proLicenseProvider.getInfo(), + ).resolves.toMatchObject({ + isPro: false, + expired: true, + }); + expect(decisions).toContain(false); + expect(keygen.calls).toHaveLength(providerCalls); + jest.useRealTimers(); + }); + + it('keeps lifetime access active when time advances', async () => { + jest.useFakeTimers(); + const start = Date.UTC(2026, 7, 26, 9, 0, 0); + jest.setSystemTime(start); + const provider = await licensed(); + + await jest.advanceTimersByTimeAsync(10 * 365 * 24 * 60 * 60 * 1_000); + + await expect( + provider.proLicenseProvider.getInfo(), + ).resolves.toMatchObject({ + isPro: true, + tier: 'lifetime', + expiry: null, + expired: false, + }); + jest.useRealTimers(); + }); + it('stops active access but keeps the credential when the seat is gone', async () => { const provider = await licensed(); // The seat was freed from another device: the key is still valid, this installation is not on it. diff --git a/__tests__/unit/navigation/useProExpiryRedirect.test.tsx b/__tests__/unit/navigation/useProExpiryRedirect.test.tsx new file mode 100644 index 000000000..a4ac3e815 --- /dev/null +++ b/__tests__/unit/navigation/useProExpiryRedirect.test.tsx @@ -0,0 +1,52 @@ +import { act, renderHook, waitFor } from '@testing-library/react-native'; +import type { NavigationContainerRefWithCurrent } from '@react-navigation/native'; +import { useProExpiryRedirect } from '../../../src/navigation/useProExpiryRedirect'; +import type { RootStackParamList } from '../../../src/navigation/types'; +import { useAppStore } from '../../../src/stores/appStore'; + +describe('the expired Pro purchase route', () => { + const navigation = { + isReady: jest.fn(() => true), + getCurrentRoute: jest.fn(() => ({ name: 'Main', key: 'main' })), + resetRoot: jest.fn(), + } as unknown as NavigationContainerRefWithCurrent; + + beforeEach(() => { + jest.clearAllMocks(); + useAppStore.getState().setHasExpiredProCredential(false); + }); + + afterEach(() => { + useAppStore.getState().setHasExpiredProCredential(false); + }); + + it('routes a cold-start expired credential when navigation becomes ready', async () => { + useAppStore.getState().setHasExpiredProCredential(true); + const { result } = renderHook(() => useProExpiryRedirect(navigation)); + + act(() => result.current()); + + await waitFor(() => + expect(navigation.resetRoot).toHaveBeenCalledWith({ + index: 0, + routes: [{ name: 'ProDetail' }], + }), + ); + }); + + it('routes an active screen when the saved deadline passes', async () => { + renderHook(() => useProExpiryRedirect(navigation)); + expect(navigation.resetRoot).not.toHaveBeenCalled(); + + act(() => { + useAppStore.getState().setHasExpiredProCredential(true); + }); + + await waitFor(() => + expect(navigation.resetRoot).toHaveBeenCalledWith({ + index: 0, + routes: [{ name: 'ProDetail' }], + }), + ); + }); +}); diff --git a/__tests__/unit/screens/DownloadManagerScreen/useDownloadManager.branches.test.ts b/__tests__/unit/screens/DownloadManagerScreen/useDownloadManager.branches.test.ts index c52dcd63a..ce537478c 100644 --- a/__tests__/unit/screens/DownloadManagerScreen/useDownloadManager.branches.test.ts +++ b/__tests__/unit/screens/DownloadManagerScreen/useDownloadManager.branches.test.ts @@ -65,7 +65,7 @@ jest.mock('../../../../src/services/modelDownloadService', () => ({ jest.mock('../../../../src/services/modelDownloadService/providers/imageProvider', () => ({ setImageDownloadOps: (...a: any[]) => mockSetImageDownloadOps(...a), })); -jest.mock('../../../../src/screens/ModelsScreen/imageDownloadActions', () => ({ +jest.mock('../../../../src/services/imageDownloadActions', () => ({ cancelSyntheticImageDownload: jest.fn(), })); jest.mock('../../../../src/screens/DownloadManagerScreen/retryHandlers', () => ({ diff --git a/__tests__/unit/screens/ModelsScreen/imageDownloadActions.test.ts b/__tests__/unit/screens/ModelsScreen/imageDownloadActions.test.ts index f09078dfd..be434a6e8 100644 --- a/__tests__/unit/screens/ModelsScreen/imageDownloadActions.test.ts +++ b/__tests__/unit/screens/ModelsScreen/imageDownloadActions.test.ts @@ -8,7 +8,7 @@ import { handleDownloadImageModel, registerAndNotify, cancelSyntheticImageDownload, -} from '../../../../src/screens/ModelsScreen/imageDownloadActions'; +} from '../../../../src/services/imageDownloadActions'; import { ImageModelDescriptor } from '../../../../src/screens/ModelsScreen/types'; import { makeImageDownloadDeps } from '../../../utils/factories'; @@ -347,8 +347,8 @@ describe('imageDownloadActions', () => { it('cancelSyntheticImageDownload does nothing when no runtime exists', async () => { const { cancelSyntheticImageDownload: cancel } = jest.requireActual( - '../../../../src/screens/ModelsScreen/imageDownloadActions', - ) as typeof import('../../../../src/screens/ModelsScreen/imageDownloadActions'); + '../../../../src/services/imageDownloadActions', + ) as typeof import('../../../../src/services/imageDownloadActions'); await expect(cancel('non-existent-model')).resolves.toBeUndefined(); }); diff --git a/__tests__/unit/screens/ModelsScreen/imageDownloadQnn.test.ts b/__tests__/unit/screens/ModelsScreen/imageDownloadQnn.test.ts index 249e94aa0..10c73a9a9 100644 --- a/__tests__/unit/screens/ModelsScreen/imageDownloadQnn.test.ts +++ b/__tests__/unit/screens/ModelsScreen/imageDownloadQnn.test.ts @@ -1,4 +1,4 @@ -import { getQnnWarningMessage, showQnnWarningAlert } from '../../../../src/screens/ModelsScreen/imageDownloadQnn'; +import { getQnnWarningMessage, showQnnWarningAlert } from '../../../../src/services/imageDownloadQnn'; import { ImageModelDescriptor } from '../../../../src/screens/ModelsScreen/types'; import { makeImageDownloadDeps } from '../../../utils/factories'; diff --git a/__tests__/unit/screens/ModelsScreen/imageDownloadResume.test.ts b/__tests__/unit/screens/ModelsScreen/imageDownloadResume.test.ts index f289fdcb3..a8a25eea7 100644 --- a/__tests__/unit/screens/ModelsScreen/imageDownloadResume.test.ts +++ b/__tests__/unit/screens/ModelsScreen/imageDownloadResume.test.ts @@ -1,7 +1,7 @@ import RNFS from 'react-native-fs'; import { unzip } from 'react-native-zip-archive'; import { backgroundDownloadService, modelManager } from '../../../../src/services'; -import { registerAndNotify } from '../../../../src/screens/ModelsScreen/imageDownloadActions'; +import { registerAndNotify } from '../../../../src/services/imageDownloadActions'; import { resumeImageDownload } from '../../../../src/screens/ModelsScreen/imageDownloadResume'; jest.mock('react-native-fs', () => ({ @@ -44,7 +44,7 @@ jest.mock('../../../../src/stores/downloadStore', () => ({ }, })); -jest.mock('../../../../src/screens/ModelsScreen/imageDownloadActions', () => ({ +jest.mock('../../../../src/services/imageDownloadActions', () => ({ registerAndNotify: jest.fn(), })); diff --git a/__tests__/unit/screens/ModelsScreen/useImageModels.branches.test.ts b/__tests__/unit/screens/ModelsScreen/useImageModels.branches.test.ts index cc8467f28..9b2c4d357 100644 --- a/__tests__/unit/screens/ModelsScreen/useImageModels.branches.test.ts +++ b/__tests__/unit/screens/ModelsScreen/useImageModels.branches.test.ts @@ -71,7 +71,7 @@ jest.mock('../../../../src/screens/ModelsScreen/utils', () => ({ matchesSdVersionFilter: (...a: any[]) => mockMatchesSdVersionFilter(...a), })); -jest.mock('../../../../src/screens/ModelsScreen/imageDownloadActions', () => ({ +jest.mock('../../../../src/services/imageDownloadActions', () => ({ handleDownloadImageModel: jest.fn(), cancelSyntheticImageDownload: (...a: any[]) => mockCancelSynthetic(...a), })); diff --git a/__tests__/unit/services/cleanTranscription.test.ts b/__tests__/unit/services/cleanTranscription.test.ts index 9981e4a94..62450840b 100644 --- a/__tests__/unit/services/cleanTranscription.test.ts +++ b/__tests__/unit/services/cleanTranscription.test.ts @@ -22,6 +22,7 @@ describe('cleanTranscription', () => { it('keeps real speech', () => { expect(cleanTranscription('hello world')).toBe('hello world'); expect(cleanTranscription(' draw a horse ')).toBe('draw a horse'); + expect(cleanTranscription('नमस्ते, कैसे हो भाई')).toBe('नमस्ते, कैसे हो भाई'); }); it('strips a leading marker but keeps the speech after it', () => { diff --git a/__tests__/unit/services/generationService.test.ts b/__tests__/unit/services/generationService.test.ts index 5d8e0eaae..8f4709d26 100644 --- a/__tests__/unit/services/generationService.test.ts +++ b/__tests__/unit/services/generationService.test.ts @@ -1562,11 +1562,12 @@ describe('generationService', () => { }); // ============================================================================ - // isUsingRemoteProvider — prefers local model when loaded + // isUsingRemoteProvider — explicit remote selection is authoritative // ============================================================================ - describe('isUsingRemoteProvider — local model wins when loaded', () => { + describe('isUsingRemoteProvider — selected remote model wins', () => { const mockRemoteProvider4 = { id: 'remote-srv', + capabilities: { supportsThinking: true }, isReady: jest.fn().mockResolvedValue(true), generate: jest.fn(), stopGeneration: jest.fn().mockResolvedValue(undefined), @@ -1581,7 +1582,7 @@ describe('generationService', () => { }); (mockedProviderRegistry as any).hasProvider = jest.fn(() => true); mockedProviderRegistry.getProvider.mockReturnValue(mockRemoteProvider4 as any); - // Local model IS loaded — service should prefer local + // A resident local model must not replace the explicit remote selection. mockedLlmService.isModelLoaded.mockReturnValue(true); }); @@ -1590,7 +1591,7 @@ describe('generationService', () => { (mockedProviderRegistry as any).hasProvider = jest.fn(() => false); }); - it('uses local LLM when local model is loaded even if remote server is configured', async () => { + it('uses the selected remote model even when a local model is resident', async () => { const convId = setupWithConversation(); mockedLlmService.generateResponse.mockImplementation(async (_msgs, { onStream: cb }: any = {}) => { cb?.({ content: 'hello' }); @@ -1601,9 +1602,8 @@ describe('generationService', () => { createMessage({ role: 'user', content: 'Hi' }), ]); - // Local generateResponse should have been called, not remote provider - expect(mockedLlmService.generateResponse).toHaveBeenCalled(); - expect(mockRemoteProvider4.generate).not.toHaveBeenCalled(); + expect(mockRemoteProvider4.generate).toHaveBeenCalled(); + expect(mockedLlmService.generateResponse).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/unit/services/generationServiceHelpers.test.ts b/__tests__/unit/services/generationServiceHelpers.test.ts index 5394bae31..2ae5b754c 100644 --- a/__tests__/unit/services/generationServiceHelpers.test.ts +++ b/__tests__/unit/services/generationServiceHelpers.test.ts @@ -10,6 +10,7 @@ jest.mock('../../../src/services/llm', () => ({ isModelLoaded: jest.fn(() => false), isCurrentlyGenerating: jest.fn(() => false), generateResponse: jest.fn(), + prepareConversationBoundary: jest.fn(() => Promise.resolve()), getGpuInfo: jest.fn(() => ({ gpu: false, gpuBackend: 'CPU', gpuLayers: 0 })), getPerformanceStats: jest.fn(() => ({ lastTokensPerSecond: 10, @@ -326,6 +327,30 @@ describe('prepareGenerationImpl', () => { await expect(prepareGenerationImpl(svc, 'conv-1')).rejects.toThrow('No model loaded'); }); + it('clears generation state when conversation preparation rejects', async () => { + const { llmService: llm } = require('../../../src/services/llm'); + const clearStreamingMessage = jest.fn(); + llm.isModelLoaded.mockReturnValue(true); + llm.prepareConversationBoundary.mockRejectedValueOnce( + new Error('Conversation preparation failed'), + ); + mockedGetState.mockReturnValue(makeLlmAppState()); + (useChatStore.getState as jest.Mock).mockReturnValue({ + startStreaming: jest.fn(), + clearStreamingMessage, + }); + const svc = makeSvc(); + svc.resetState.mockImplementation(() => { + svc.state.isGenerating = false; + }); + + await expect(prepareGenerationImpl(svc, 'conv-1')).rejects.toThrow( + 'Conversation preparation failed', + ); + expect(svc.state.isGenerating).toBe(false); + expect(clearStreamingMessage).toHaveBeenCalledTimes(1); + }); + }); // --------------------------------------------------------------------------- diff --git a/__tests__/unit/services/httpClient.test.ts b/__tests__/unit/services/httpClient.test.ts index 9c55a37a0..41c7e7c1a 100644 --- a/__tests__/unit/services/httpClient.test.ts +++ b/__tests__/unit/services/httpClient.test.ts @@ -30,11 +30,16 @@ describe('httpClient', () => { // ─── SSE Parsing Tests ───────────────────────────────────────────────────── describe('parseSSEStream', () => { - async function parseSSEData(...chunks: string[]): Promise<{ events: any[]; releaseLock: jest.Mock }> { + async function parseSSEData( + ...chunks: string[] + ): Promise<{ events: any[]; releaseLock: jest.Mock }> { const encoder = new TextEncoder(); const readMock = jest.fn(); chunks.forEach(chunk => { - readMock.mockResolvedValueOnce({ done: false, value: encoder.encode(chunk) }); + readMock.mockResolvedValueOnce({ + done: false, + value: encoder.encode(chunk), + }); }); readMock.mockResolvedValueOnce({ done: true, value: undefined }); const releaseLock = jest.fn(); @@ -49,7 +54,9 @@ describe('httpClient', () => { } it('should parse simple SSE events', async () => { - const { events, releaseLock } = await parseSSEData('event: message\ndata: {"text":"hello"}\n\n'); + const { events, releaseLock } = await parseSSEData( + 'event: message\ndata: {"text":"hello"}\n\n', + ); expect(events).toHaveLength(1); expect(events[0]).toEqual({ event: 'message', data: '{"text":"hello"}' }); expect(releaseLock).toHaveBeenCalled(); @@ -58,7 +65,7 @@ describe('httpClient', () => { it('should parse multiple SSE events', async () => { const { events, releaseLock } = await parseSSEData( 'event: message\ndata: {"text":"first"}\n\n' + - 'event: message\ndata: {"text":"second"}\n\n' + 'event: message\ndata: {"text":"second"}\n\n', ); expect(events).toHaveLength(2); expect(events[0].data).toBe('{"text":"first"}'); @@ -67,7 +74,9 @@ describe('httpClient', () => { }); it('should handle multi-line data', async () => { - const { events, releaseLock } = await parseSSEData('data: line1\ndata: line2\n\n'); + const { events, releaseLock } = await parseSSEData( + 'data: line1\ndata: line2\n\n', + ); expect(events).toHaveLength(1); expect(events[0].data).toBe('line1\nline2'); expect(releaseLock).toHaveBeenCalled(); @@ -94,7 +103,9 @@ describe('httpClient', () => { }); it('should handle events with id field', async () => { - const { events } = await parseSSEData('id: event-123\nevent: message\ndata: {"text":"hello"}\n\n'); + const { events } = await parseSSEData( + 'id: event-123\nevent: message\ndata: {"text":"hello"}\n\n', + ); expect(events).toHaveLength(1); expect(events[0].id).toBe('event-123'); expect(events[0].event).toBe('message'); @@ -108,14 +119,19 @@ describe('httpClient', () => { }); it('should handle chunked data correctly', async () => { - const { events, releaseLock } = await parseSSEData('event: message\ndata: hel', 'lo\n\n'); + const { events, releaseLock } = await parseSSEData( + 'event: message\ndata: hel', + 'lo\n\n', + ); expect(events).toHaveLength(1); expect(events[0].data).toBe('hello'); expect(releaseLock).toHaveBeenCalled(); }); it('should handle event with id field', async () => { - const { events } = await parseSSEData('event: message\nid: 123\ndata: hello\n\n'); + const { events } = await parseSSEData( + 'event: message\nid: 123\ndata: hello\n\n', + ); expect(events).toHaveLength(1); expect(events[0].id).toBe('123'); expect(events[0].event).toBe('message'); @@ -175,7 +191,9 @@ describe('httpClient', () => { }); it('should parse error messages', () => { - const event = { data: '{"error":{"message":"Rate limit exceeded","type":"rate_limit"}}' }; + const event = { + data: '{"error":{"message":"Rate limit exceeded","type":"rate_limit"}}', + }; const result = parseOpenAIMessage(event); expect(result).not.toBeNull(); @@ -184,7 +202,7 @@ describe('httpClient', () => { it('should parse tool calls', () => { const event = { - data: '{"choices":[{"delta":{"tool_calls":[{"id":"call_123","function":{"name":"search","arguments":"{\\"query\\""}}]}}]}' + data: '{"choices":[{"delta":{"tool_calls":[{"id":"call_123","function":{"name":"search","arguments":"{\\"query\\""}}]}}]}', }; const result = parseOpenAIMessage(event); @@ -211,7 +229,9 @@ describe('httpClient', () => { describe('parseAnthropicMessage', () => { it('should parse content_block_delta', () => { - const event = { data: '{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}' }; + const event = { + data: '{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}', + }; const result = parseAnthropicMessage(event); expect(result).not.toBeNull(); @@ -220,7 +240,9 @@ describe('httpClient', () => { }); it('should parse message_start', () => { - const event = { data: '{"type":"message_start","message":{"id":"msg_123"}}' }; + const event = { + data: '{"type":"message_start","message":{"id":"msg_123"}}', + }; const result = parseAnthropicMessage(event); expect(result).not.toBeNull(); @@ -271,8 +293,22 @@ describe('httpClient', () => { expect(isPrivateNetworkEndpoint('http://169.254.0.1:11434')).toBe(true); }); + it('accepts only the Tailscale CGNAT range as private', () => { + expect(isPrivateNetworkEndpoint('http://100.64.0.0:7878')).toBe(true); + expect(isPrivateNetworkEndpoint('http://100.116.255.25:7878')).toBe(true); + expect(isPrivateNetworkEndpoint('http://100.127.255.255:7878')).toBe( + true, + ); + expect(isPrivateNetworkEndpoint('http://100.63.255.255:7878')).toBe( + false, + ); + expect(isPrivateNetworkEndpoint('http://100.128.0.0:7878')).toBe(false); + }); + it('should detect .local (mDNS) as private', () => { - expect(isPrivateNetworkEndpoint('http://myserver.local:11434')).toBe(true); + expect(isPrivateNetworkEndpoint('http://myserver.local:11434')).toBe( + true, + ); }); it('should detect public internet as NOT private', () => { @@ -296,7 +332,9 @@ describe('httpClient', () => { json: () => Promise.resolve(mockData), } as unknown as Response); - const result = await fetchWithTimeout('http://test.com/api', { timeout: 5000 }); + const result = await fetchWithTimeout('http://test.com/api', { + timeout: 5000, + }); expect(result).toEqual(mockData); }); @@ -308,7 +346,9 @@ describe('httpClient', () => { text: () => Promise.resolve('ok'), } as unknown as Response); - const result = await fetchWithTimeout('http://test.com/page', { timeout: 5000 }); + const result = await fetchWithTimeout('http://test.com/page', { + timeout: 5000, + }); expect(result).toBe('ok'); }); @@ -320,8 +360,9 @@ describe('httpClient', () => { text: () => Promise.resolve('Not Found'), } as Response); - await expect(fetchWithTimeout('http://test.com/missing', { timeout: 5000 })) - .rejects.toThrow('HTTP 404'); + await expect( + fetchWithTimeout('http://test.com/missing', { timeout: 5000 }), + ).rejects.toThrow('HTTP 404'); }); it('should timeout after specified duration', async () => { @@ -335,13 +376,14 @@ describe('httpClient', () => { }); await expect( - fetchWithTimeout('http://test.com/slow', { timeout: 100 }) + fetchWithTimeout('http://test.com/slow', { timeout: 100 }), ).rejects.toThrow(); }); it('should retry on transient errors', async () => { const mockData = { success: true }; - jest.spyOn(global, 'fetch') + jest + .spyOn(global, 'fetch') .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValueOnce({ ok: true, @@ -352,7 +394,7 @@ describe('httpClient', () => { const result = await fetchWithTimeout('http://test.com/api', { timeout: 5000, retries: 1, - retryDelay: 0 // No delay for test + retryDelay: 0, // No delay for test }); expect(result).toEqual({ success: true }); @@ -364,8 +406,9 @@ describe('httpClient', () => { abortError.name = 'AbortError'; jest.spyOn(global, 'fetch').mockRejectedValue(abortError); - await expect(fetchWithTimeout('http://test.com/api', { timeout: 5000 })) - .rejects.toThrow('Request cancelled'); + await expect( + fetchWithTimeout('http://test.com/api', { timeout: 5000 }), + ).rejects.toThrow('Request cancelled'); }); it('should fallback to text when content-type header is missing', async () => { @@ -375,7 +418,9 @@ describe('httpClient', () => { text: () => Promise.resolve('plain text response'), } as unknown as Response); - const result = await fetchWithTimeout('http://test.com/api', { timeout: 5000 }); + const result = await fetchWithTimeout('http://test.com/api', { + timeout: 5000, + }); expect(result).toBe('plain text response'); }); @@ -387,15 +432,17 @@ describe('httpClient', () => { text: () => Promise.reject(new Error('text failed')), } as unknown as Response); - await expect(fetchWithTimeout('http://test.com/error', { timeout: 5000 })) - .rejects.toThrow('HTTP 500: Unknown error'); + await expect( + fetchWithTimeout('http://test.com/error', { timeout: 5000 }), + ).rejects.toThrow('HTTP 500: Unknown error'); }); it('should handle non-Error thrown values', async () => { jest.spyOn(global, 'fetch').mockRejectedValue('string error'); - await expect(fetchWithTimeout('http://test.com/api', { timeout: 5000, retries: 0 })) - .rejects.toThrow('string error'); + await expect( + fetchWithTimeout('http://test.com/api', { timeout: 5000, retries: 0 }), + ).rejects.toThrow('string error'); }); }); @@ -423,7 +470,9 @@ describe('httpClient', () => { }); it('should return error for unreachable endpoint', async () => { - (global.fetch as jest.Mock).mockRejectedValue(new Error('Connection refused')); + (global.fetch as jest.Mock).mockRejectedValue( + new Error('Connection refused'), + ); const result = await testEndpoint('http://192.168.1.50:11434', 5000); @@ -458,6 +507,32 @@ describe('httpClient', () => { expect(result.success).toBe(true); }); + it('bounds the full fallback probe sequence with one timeout', async () => { + jest.useFakeTimers(); + try { + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockImplementation( + (_url, init: RequestInit) => + new Promise((_resolve, reject) => { + const abort = () => reject(new Error('aborted')); + if (init.signal?.aborted) abort(); + else + init.signal?.addEventListener('abort', abort, { once: true }); + }), + ); + + const pending = testEndpoint('http://192.168.1.50:11434', 50); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(50); + + await expect(pending).resolves.toMatchObject({ success: false }); + expect(global.fetch).toHaveBeenCalledTimes(4); + } finally { + jest.useRealTimers(); + } + }); + it('should strip trailing slashes from endpoint', async () => { (global.fetch as jest.Mock).mockResolvedValue({ ok: true, @@ -468,7 +543,7 @@ describe('httpClient', () => { expect(global.fetch).toHaveBeenCalledWith( 'http://192.168.1.50:11434/v1/models', - expect.any(Object) + expect.any(Object), ); }); }); @@ -481,7 +556,7 @@ describe('httpClient', () => { // Helper: mock the FileReader global with a success result function mockFileReaderSuccess(result = 'data:image/png;base64,encoded') { const mockReader = { - readAsDataURL: jest.fn(function(this: any) { + readAsDataURL: jest.fn(function (this: any) { setTimeout(() => { this.result = result; if (this.onload) this.onload({ target: this }); @@ -498,7 +573,7 @@ describe('httpClient', () => { // Helper: mock the FileReader global to trigger an error function mockFileReaderError() { const mockReader = { - readAsDataURL: jest.fn(function(this: any) { + readAsDataURL: jest.fn(function (this: any) { setTimeout(() => { if (this.onerror) this.onerror({ target: this }); }, 0); @@ -537,7 +612,7 @@ describe('httpClient', () => { RNFS.exists.mockResolvedValue(false); await expect(imageToBase64DataUrl('file:///missing.png')).rejects.toThrow( - 'Image file not found' + 'Image file not found', ); }); @@ -598,9 +673,9 @@ describe('httpClient', () => { status: 404, } as Response); - await expect(imageToBase64DataUrl('http://example.com/missing.png')).rejects.toThrow( - 'Failed to fetch image: 404' - ); + await expect( + imageToBase64DataUrl('http://example.com/missing.png'), + ).rejects.toThrow('Failed to fetch image: 404'); }); it('should throw on FileReader error', async () => { @@ -612,7 +687,9 @@ describe('httpClient', () => { mockFileReaderError(); - await expect(imageToBase64DataUrl('http://example.com/image.png')).rejects.toThrow('Failed to read image as base64'); + await expect( + imageToBase64DataUrl('http://example.com/image.png'), + ).rejects.toThrow('Failed to read image as base64'); }); }); @@ -670,9 +747,10 @@ describe('httpClient', () => { }) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ - data: [{ id: 'model.gguf' }, { id: 'other.gguf' }], - }), + json: () => + Promise.resolve({ + data: [{ id: 'model.gguf' }, { id: 'other.gguf' }], + }), }); const result = await detectServerType('http://localhost:1234', 5000); @@ -684,7 +762,8 @@ describe('httpClient', () => { (global.fetch as jest.Mock).mockResolvedValue({ ok: true, headers: { get: () => null }, - json: () => Promise.resolve({ object: 'list', data: [{ id: 'gpt-4' }] }), + json: () => + Promise.resolve({ object: 'list', data: [{ id: 'gpt-4' }] }), }); const result = await detectServerType('http://localhost:8080', 5000); @@ -732,7 +811,7 @@ describe('httpClient', () => { expect(global.fetch).toHaveBeenCalledWith( 'http://localhost:11434/v1/models', - expect.any(Object) + expect.any(Object), ); }); @@ -786,19 +865,27 @@ describe('httpClient', () => { // Capture event handlers Object.defineProperty(mockXHR, 'onreadystatechange', { - set: (fn: () => void) => { onReadyStateChange = fn; }, + set: (fn: () => void) => { + onReadyStateChange = fn; + }, get: () => onReadyStateChange, }); Object.defineProperty(mockXHR, 'onprogress', { - set: (fn: () => void) => { onProgress = fn; }, + set: (fn: () => void) => { + onProgress = fn; + }, get: () => onProgress, }); Object.defineProperty(mockXHR, 'onerror', { - set: (fn: () => void) => { onError = fn; }, + set: (fn: () => void) => { + onError = fn; + }, get: () => onError, }); Object.defineProperty(mockXHR, 'ontimeout', { - set: (fn: () => void) => { onTimeout = fn; }, + set: (fn: () => void) => { + onTimeout = fn; + }, get: () => onTimeout, }); @@ -817,7 +904,11 @@ describe('httpClient', () => { let streamEvents: any[] = []; function startStream(headers: Record = {}): Promise { - return createStreamingRequest(TEST_ENDPOINT, { body: { model: 'test' }, headers }, (e) => streamEvents.push(e)); + return createStreamingRequest( + TEST_ENDPOINT, + { body: { model: 'test' }, headers }, + e => streamEvents.push(e), + ); } // Helper: simulate a progress event with given SSE response text @@ -837,12 +928,21 @@ describe('httpClient', () => { } it('should make POST request with correct headers', async () => { - const _promise = startStream({ 'Authorization': 'Bearer token' }); + const _promise = startStream({ Authorization: 'Bearer token' }); expect(mockXHR.open).toHaveBeenCalledWith('POST', TEST_ENDPOINT, true); - expect(mockXHR.setRequestHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); - expect(mockXHR.setRequestHeader).toHaveBeenCalledWith('Accept', 'text/event-stream'); - expect(mockXHR.setRequestHeader).toHaveBeenCalledWith('Authorization', 'Bearer token'); + expect(mockXHR.setRequestHeader).toHaveBeenCalledWith( + 'Content-Type', + 'application/json', + ); + expect(mockXHR.setRequestHeader).toHaveBeenCalledWith( + 'Accept', + 'text/event-stream', + ); + expect(mockXHR.setRequestHeader).toHaveBeenCalledWith( + 'Authorization', + 'Bearer token', + ); expect(mockXHR.send).toHaveBeenCalledWith('{"model":"test"}'); }); @@ -855,6 +955,21 @@ describe('httpClient', () => { expect(streamEvents[0].data).toBe('{"text":"hello"}'); }); + it('rejects a credential downgrade before parsing SSE progress', async () => { + const onEvent = jest.fn(); + const promise = createStreamingRequest( + 'https://desktop.example.test/v1/chat', + { body: {}, headers: { Authorization: 'Bearer token' } }, + onEvent, + ); + mockXHR.responseURL = 'http://192.168.1.30:7878/v1/chat'; + simulateProgress('data: {"text":"must-not-run"}\n\n'); + + await expect(promise).rejects.toThrow('redirected credentials'); + expect(onEvent).not.toHaveBeenCalled(); + expect(mockXHR.abort).toHaveBeenCalled(); + }); + it('should resolve on successful completion', async () => { const promise = startStream(); @@ -863,6 +978,17 @@ describe('httpClient', () => { await expect(promise).resolves.toBeUndefined(); }); + it('rejects a credentialed HTTPS stream that reports an HTTP redirect target', async () => { + const promise = createStreamingRequest( + 'https://desktop.example.test/v1/chat', + { body: {}, headers: { Authorization: 'Bearer token' } }, + jest.fn(), + ); + mockXHR.responseURL = 'http://192.168.1.30:7878/v1/chat'; + simulateComplete('data: final\n\n'); + await expect(promise).rejects.toThrow('redirected credentials'); + }); + it('should reject on HTTP error', async () => { const promise = startStream(); @@ -999,19 +1125,26 @@ describe('httpClient', () => { (global as any).XMLHttpRequest = jest.fn(() => mockXHRThatThrows); - await expect(createStreamingRequest( - 'http://localhost:11434/api/chat', - { body: { model: 'test' }, headers: {} }, - () => {} - )).rejects.toThrow('Send failed'); + await expect( + createStreamingRequest( + 'http://localhost:11434/api/chat', + { body: { model: 'test' }, headers: {} }, + () => {}, + ), + ).rejects.toThrow('Send failed'); }); it('should abort XHR when signal fires', async () => { const controller = new AbortController(); const promise = createStreamingRequest( TEST_ENDPOINT, - { body: { model: 'test' }, headers: {}, timeout: 300000, signal: controller.signal }, - (e) => streamEvents.push(e), + { + body: { model: 'test' }, + headers: {}, + timeout: 300000, + signal: controller.signal, + }, + e => streamEvents.push(e), ); controller.abort(); @@ -1070,9 +1203,10 @@ describe('httpClient', () => { .mockResolvedValueOnce({ ok: false, status: 404 }) // /api/tags fails .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve({ - data: [{ id: 'some-model' }, { id: 'other-model' }], // no .gguf - }), + json: () => + Promise.resolve({ + data: [{ id: 'some-model' }, { id: 'other-model' }], // no .gguf + }), }); const result = await detectServerType('http://localhost:1234', 5000); @@ -1143,7 +1277,11 @@ describe('httpClient', () => { it('resolves and calls onLine for each complete NDJSON line', async () => { const onLine = jest.fn(); - const promise = createNDJSONStreamingRequest('http://localhost/api/chat', { body: {} }, onLine); + const promise = createNDJSONStreamingRequest( + 'http://localhost/api/chat', + { body: {} }, + onLine, + ); simulateSuccess('{"done":false}\n{"done":true}\n'); await promise; expect(onLine).toHaveBeenCalledTimes(2); @@ -1151,9 +1289,40 @@ describe('httpClient', () => { expect(onLine).toHaveBeenCalledWith({ done: true }); }); + it('rejects a credential downgrade before parsing NDJSON progress', async () => { + const onLine = jest.fn(); + const promise = createNDJSONStreamingRequest( + 'https://desktop.example.test/api/chat', + { body: {}, headers: { Authorization: 'Bearer token' } }, + onLine, + ); + mockXHR.responseURL = 'http://192.168.1.30:11434/api/chat'; + mockXHR.responseText = '{"done":true}\n'; + mockXHR.onprogress?.(); + + await expect(promise).rejects.toThrow('redirected credentials'); + expect(onLine).not.toHaveBeenCalled(); + expect(mockXHR.abort).toHaveBeenCalled(); + }); + + it('rejects a credentialed HTTPS stream that reports an HTTP redirect target', async () => { + const promise = createNDJSONStreamingRequest( + 'https://desktop.example.test/api/chat', + { body: {}, headers: { Authorization: 'Bearer token' } }, + jest.fn(), + ); + mockXHR.responseURL = 'http://192.168.1.30:11434/api/chat'; + simulateSuccess(); + await expect(promise).rejects.toThrow('redirected credentials'); + }); + it('flushes partial buffered line on readyState=4', async () => { const onLine = jest.fn(); - const promise = createNDJSONStreamingRequest('http://localhost/api/chat', { body: {} }, onLine); + const promise = createNDJSONStreamingRequest( + 'http://localhost/api/chat', + { body: {} }, + onLine, + ); // No trailing newline — sits in lineBuffer until completion simulateSuccess('{"done":true}'); await promise; @@ -1161,7 +1330,11 @@ describe('httpClient', () => { }); it('rejects on HTTP error status', async () => { - const promise = createNDJSONStreamingRequest('http://localhost/api/chat', { body: {} }, jest.fn()); + const promise = createNDJSONStreamingRequest( + 'http://localhost/api/chat', + { body: {} }, + jest.fn(), + ); mockXHR.responseText = 'Internal Server Error'; mockXHR.readyState = 4; mockXHR.status = 500; @@ -1170,20 +1343,32 @@ describe('httpClient', () => { }); it('rejects on network error', async () => { - const promise = createNDJSONStreamingRequest('http://localhost/api/chat', { body: {} }, jest.fn()); + const promise = createNDJSONStreamingRequest( + 'http://localhost/api/chat', + { body: {} }, + jest.fn(), + ); mockXHR.onerror?.(); await expect(promise).rejects.toThrow('Network error'); }); it('rejects on timeout', async () => { - const promise = createNDJSONStreamingRequest('http://localhost/api/chat', { body: {} }, jest.fn()); + const promise = createNDJSONStreamingRequest( + 'http://localhost/api/chat', + { body: {} }, + jest.fn(), + ); mockXHR.ontimeout?.(); await expect(promise).rejects.toThrow('Request timeout'); }); it('skips empty/blank lines', async () => { const onLine = jest.fn(); - const promise = createNDJSONStreamingRequest('http://localhost/api/chat', { body: {} }, onLine); + const promise = createNDJSONStreamingRequest( + 'http://localhost/api/chat', + { body: {} }, + onLine, + ); simulateSuccess('\n\n{"done":true}\n\n'); await promise; expect(onLine).toHaveBeenCalledTimes(1); @@ -1191,7 +1376,11 @@ describe('httpClient', () => { it('warns and skips invalid JSON lines', async () => { const onLine = jest.fn(); - const promise = createNDJSONStreamingRequest('http://localhost/api/chat', { body: {} }, onLine); + const promise = createNDJSONStreamingRequest( + 'http://localhost/api/chat', + { body: {} }, + onLine, + ); simulateSuccess('not-json\n{"ok":true}\n'); await promise; expect(onLine).toHaveBeenCalledTimes(1); @@ -1206,12 +1395,19 @@ describe('httpClient', () => { ); simulateSuccess(''); await promise; - expect(mockXHR.setRequestHeader).toHaveBeenCalledWith('Authorization', 'Bearer token'); + expect(mockXHR.setRequestHeader).toHaveBeenCalledWith( + 'Authorization', + 'Bearer token', + ); }); it('processes onprogress chunks and merges partial lines', async () => { const onLine = jest.fn(); - const promise = createNDJSONStreamingRequest('http://localhost/api/chat', { body: {} }, onLine); + const promise = createNDJSONStreamingRequest( + 'http://localhost/api/chat', + { body: {} }, + onLine, + ); // First progress event delivers half a line mockXHR.responseText = '{"a":1}\n{"b":'; mockXHR.onprogress?.(); @@ -1226,7 +1422,11 @@ describe('httpClient', () => { it('warns and skips invalid JSON in buffered final line', async () => { const onLine = jest.fn(); - const promise = createNDJSONStreamingRequest('http://localhost/api/chat', { body: {} }, onLine); + const promise = createNDJSONStreamingRequest( + 'http://localhost/api/chat', + { body: {} }, + onLine, + ); // No trailing newline so it ends up in lineBuffer — invalid JSON simulateSuccess('not-valid-json'); await promise; @@ -1234,10 +1434,15 @@ describe('httpClient', () => { }); it('rejects when xhr.send throws', async () => { - mockXHR.send = jest.fn(() => { throw new Error('send failed'); }); - const promise = createNDJSONStreamingRequest('http://localhost/api/chat', { body: {} }, jest.fn()); + mockXHR.send = jest.fn(() => { + throw new Error('send failed'); + }); + const promise = createNDJSONStreamingRequest( + 'http://localhost/api/chat', + { body: {} }, + jest.fn(), + ); await expect(promise).rejects.toThrow('send failed'); }); - }); -}); \ No newline at end of file +}); diff --git a/__tests__/unit/services/llm.test.ts b/__tests__/unit/services/llm.test.ts index 14971cd40..d6276e69c 100644 --- a/__tests__/unit/services/llm.test.ts +++ b/__tests__/unit/services/llm.test.ts @@ -65,6 +65,7 @@ describe('LLMService', () => { // Reset singleton state (llmService as any).context = null; (llmService as any).currentModelPath = null; + (llmService as any).nativeConversationId = null; (llmService as any).isGenerating = false; (llmService as any).multimodalSupport = null; (llmService as any).multimodalInitialized = false; @@ -848,6 +849,21 @@ describe('LLMService', () => { it('is safe without context', async () => { await llmService.clearKVCache(); // Should not throw }); + + it('fully clears once when the native context moves to another chat', async () => { + mockedRNFS.exists.mockResolvedValue(true); + const ctx = createMockLlamaContext(); + mockedInitLlama.mockResolvedValue(ctx as any); + await llmService.loadModel('/models/test.gguf'); + + await llmService.prepareConversationBoundary('chat-a'); + await llmService.prepareConversationBoundary('chat-a'); + await llmService.prepareConversationBoundary('chat-b'); + + expect(ctx.clearCache).toHaveBeenCalledTimes(2); + expect(ctx.clearCache).toHaveBeenNthCalledWith(1, true); + expect(ctx.clearCache).toHaveBeenNthCalledWith(2, true); + }); }); // ======================================================================== diff --git a/__tests__/unit/services/modelDownloadService.test.ts b/__tests__/unit/services/modelDownloadService.test.ts index af0c63f34..90f89cad2 100644 --- a/__tests__/unit/services/modelDownloadService.test.ts +++ b/__tests__/unit/services/modelDownloadService.test.ts @@ -206,4 +206,14 @@ describe('ModelDownloadService', () => { p._onChange?.(); expect(listener).toHaveBeenCalled(); }); + + it('keeps a newer registration when an old cleanup handle runs', async () => { + const provider = makeProvider('text', [dl('text:a', 'text')]); + const oldCleanup = modelDownloadService.register(provider); + const currentCleanup = modelDownloadService.register(provider); + oldCleanup(); + expect((await modelDownloadService.list()).map(item => item.id)).toEqual(['text:a']); + currentCleanup(); + expect(await modelDownloadService.list()).toEqual([]); + }); }); diff --git a/__tests__/unit/services/modelResidency.test.ts b/__tests__/unit/services/modelResidency.test.ts index ebd455e8a..31d428c1d 100644 --- a/__tests__/unit/services/modelResidency.test.ts +++ b/__tests__/unit/services/modelResidency.test.ts @@ -337,6 +337,19 @@ describe('ModelResidencyManager', () => { modelResidencyManager.setBudgetOverrideMB(1000); }); + it('keeps a replacement resident when its old owner unregisters', () => { + const oldOwner = modelResidencyManager.register( + { key: 'text', type: 'text', sizeMB: 400 }, async () => {}, 1, + ); + const currentOwner = modelResidencyManager.register( + { key: 'text', type: 'text', sizeMB: 800 }, async () => {}, 2, + ); + expect(modelResidencyManager.unregister('text', oldOwner)).toBe(false); + expect(modelResidencyManager.isResident('text')).toBe(true); + expect(modelResidencyManager.unregister('text', currentOwner)).toBe(true); + expect(modelResidencyManager.isResident('text')).toBe(false); + }); + it('loads a model and tracks it as resident', async () => { const load = jest.fn(async () => {}); const res = await modelResidencyManager.ensureResident( diff --git a/__tests__/unit/services/networkReconnect.test.ts b/__tests__/unit/services/networkReconnect.test.ts new file mode 100644 index 000000000..f97ed6c7f --- /dev/null +++ b/__tests__/unit/services/networkReconnect.test.ts @@ -0,0 +1,97 @@ +import { AppState } from 'react-native'; +import { getIpAddress } from 'react-native-device-info'; +import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; +import { + startNetworkReconnectWatcher, + stopNetworkReconnectWatcher, +} from '../../../src/services/networkReconnect'; + +jest.mock('react-native-device-info', () => ({ + getIpAddress: jest.fn(), + isEmulator: jest.fn(() => Promise.resolve(false)), +})); + +describe('network reconnect watcher', () => { + let appStateListener: ((state: 'active' | 'background') => void) | undefined; + const originalFetch = global.fetch; + + beforeEach(() => { + jest.useFakeTimers(); + stopNetworkReconnectWatcher(); + jest.clearAllMocks(); + useRemoteServerStore.getState().clearAllServers(); + global.fetch = jest.fn(() => Promise.reject(new Error('server offline'))); + jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((_event, listener) => { + appStateListener = listener as (state: 'active' | 'background') => void; + return { remove: jest.fn() }; + }); + Object.defineProperty(AppState, 'currentState', { + configurable: true, + value: 'active', + }); + }); + + afterEach(() => { + stopNetworkReconnectWatcher(); + useRemoteServerStore.getState().clearAllServers(); + global.fetch = originalFetch; + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('validates the active connection after a same-IP foreground rejoin', async () => { + (getIpAddress as jest.Mock).mockResolvedValue('192.168.1.20'); + const store = useRemoteServerStore.getState(); + const serverId = store.addServer({ + name: 'Desktop', + endpoint: 'http://192.168.1.10:7878', + providerType: 'openai-compatible', + }); + store.setActiveServerId(serverId); + + startNetworkReconnectWatcher(); + await Promise.resolve(); + appStateListener?.('background'); + appStateListener?.('active'); + await Promise.resolve(); + jest.advanceTimersByTime(2_500); + for (let i = 0; i < 20; i++) await Promise.resolve(); + + expect( + useRemoteServerStore.getState().serverHealth[serverId]?.isHealthy, + ).toBe(false); + }); + + it('discards an IP lookup that settles after teardown', async () => { + let resolveIp: ((ip: string) => void) | undefined; + (getIpAddress as jest.Mock).mockImplementation( + () => + new Promise(resolve => { + resolveIp = resolve; + }), + ); + + startNetworkReconnectWatcher(); + stopNetworkReconnectWatcher(); + resolveIp?.('192.168.1.20'); + await Promise.resolve(); + jest.runOnlyPendingTimers(); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('does not validate an unchanged IP during steady-state polling', async () => { + (getIpAddress as jest.Mock).mockResolvedValue('192.168.1.20'); + + startNetworkReconnectWatcher(); + await Promise.resolve(); + jest.advanceTimersByTime(15_000); + await Promise.resolve(); + jest.advanceTimersByTime(2_500); + await Promise.resolve(); + + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/unit/services/proPrompt.test.ts b/__tests__/unit/services/proPrompt.test.ts index d6021fe17..35fae80c3 100644 --- a/__tests__/unit/services/proPrompt.test.ts +++ b/__tests__/unit/services/proPrompt.test.ts @@ -22,40 +22,33 @@ function makeStore(overrides: any = {}) { hasRegisteredPro: false, isProActive: false, proAhaTriggeredBy: null, - textGenerationCount: 3, - imageGenerationCount: 3, + textGenerationCount: 10, + imageGenerationCount: 10, setProAhaTriggeredBy: jest.fn(), ...overrides, }; } describe('shouldShowProAha', () => { - it('returns true at threshold (3)', () => { - expect(shouldShowProAha(3)).toBe(true); + it('returns true at threshold (10)', () => { + expect(shouldShowProAha(10)).toBe(true); }); it('returns false below threshold', () => { expect(shouldShowProAha(2)).toBe(false); expect(shouldShowProAha(1)).toBe(false); + expect(shouldShowProAha(9)).toBe(false); }); - it('returns false between threshold and repeat start', () => { + it('returns false after the one automatic offer', () => { expect(shouldShowProAha(4)).toBe(false); + expect(shouldShowProAha(11)).toBe(false); expect(shouldShowProAha(14)).toBe(false); - }); - - it('returns true at repeat start (15)', () => { - expect(shouldShowProAha(15)).toBe(true); - }); - - it('returns true at each repeat interval (25, 35)', () => { - expect(shouldShowProAha(25)).toBe(true); - expect(shouldShowProAha(35)).toBe(true); - }); - - it('returns false between repeat intervals', () => { + expect(shouldShowProAha(15)).toBe(false); expect(shouldShowProAha(16)).toBe(false); expect(shouldShowProAha(24)).toBe(false); + expect(shouldShowProAha(25)).toBe(false); + expect(shouldShowProAha(35)).toBe(false); }); }); @@ -110,7 +103,7 @@ describe('checkProPromptForText', () => { }); it('triggers when all conditions met', () => { - const store = makeStore({ textGenerationCount: 3 }); + const store = makeStore({ textGenerationCount: 10 }); mockedGetState.mockReturnValue(store); checkProPromptForText(0); expect(store.setProAhaTriggeredBy).toHaveBeenCalledWith('text'); @@ -122,14 +115,14 @@ describe('checkProPromptForImage', () => { afterEach(() => jest.useRealTimers()); it('triggers for image when count meets threshold', () => { - const store = makeStore({ imageGenerationCount: 3 }); + const store = makeStore({ imageGenerationCount: 10 }); mockedGetState.mockReturnValue(store); checkProPromptForImage(0); expect(store.setProAhaTriggeredBy).toHaveBeenCalledWith('image'); }); it('skips when hasRegisteredPro=true', () => { - const store = makeStore({ hasRegisteredPro: true, imageGenerationCount: 3 }); + const store = makeStore({ hasRegisteredPro: true, imageGenerationCount: 10 }); mockedGetState.mockReturnValue(store); checkProPromptForImage(0); expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled(); diff --git a/__tests__/unit/services/providers/openAICompatibleProvider.test.ts b/__tests__/unit/services/providers/openAICompatibleProvider.test.ts index 48cd72073..2c202c0d8 100644 --- a/__tests__/unit/services/providers/openAICompatibleProvider.test.ts +++ b/__tests__/unit/services/providers/openAICompatibleProvider.test.ts @@ -228,7 +228,7 @@ describe('OpenAICompatibleProvider', () => { it('should include API key in headers when provided', async () => { const secureProvider = new OpenAICompatibleProvider('secure', { - endpoint: 'http://api.example.com', + endpoint: 'https://api.example.com', apiKey: 'secret-key', modelId: 'test-model', }); @@ -346,7 +346,7 @@ describe('OpenAICompatibleProvider', () => { endpoint?: string; }) => { const p = new OpenAICompatibleProvider('s', { - endpoint: opts.endpoint ?? 'http://example.com:9999', + endpoint: opts.endpoint ?? 'https://example.com:9999', modelId: 'qwen3', }); await p.loadModel('qwen3'); @@ -947,4 +947,4 @@ describe('OpenAICompatibleProvider', () => { expect(provider.isModelLoaded()).toBe(false); }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/unit/services/remoteMediaRuntime.test.ts b/__tests__/unit/services/remoteMediaRuntime.test.ts new file mode 100644 index 000000000..54db6580c --- /dev/null +++ b/__tests__/unit/services/remoteMediaRuntime.test.ts @@ -0,0 +1,175 @@ +import { remoteMediaRuntime } from '../../../src/services/remoteMediaRuntime'; +import * as Keychain from 'react-native-keychain'; +import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; +import { whisperService } from '../../../src/services/whisperService'; +import RNFS from 'react-native-fs'; +import { + activeRemoteVoiceServer, + synthesizeRemoteVoiceFile, +} from '../../../src/services/remoteVoicePlayback'; +import { + remoteServerCapabilities, + type RemoteServer, +} from '../../../src/types'; + +const server: RemoteServer = { + id: 'desktop-study', + name: 'Study Mac', + endpoint: 'http://192.168.1.30:7878/', + providerType: 'openai-compatible', + createdAt: '2026-08-29T00:00:00.000Z', + mediaModels: { + image: 'flux-schnell', + transcription: 'whisper-large-v3', + voice: 'kokoro', + }, +}; + +function jsonResponse(payload: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => 'application/json' }, + json: async () => payload, + text: async () => JSON.stringify(payload), + arrayBuffer: async () => new ArrayBuffer(0), + } as unknown as Response; +} + +describe('remoteMediaRuntime', () => { + const originalFetch = global.fetch; + + beforeEach(async () => { + useRemoteServerStore.getState().clearAllServers(); + jest.spyOn(Keychain, 'getGenericPassword').mockResolvedValue({ + service: `ai.offgridmobile.servers.${server.id}`, + storage: 'AES_GCM', + username: `server_${server.id}`, + password: 'device-secret', + } as never); + }); + + afterEach(() => { + jest.restoreAllMocks(); + global.fetch = originalFetch; + }); + + it('derives media capabilities from the server model IDs', () => { + expect(remoteServerCapabilities(server)).toEqual({ + imageGeneration: true, + transcription: true, + voice: true, + }); + expect(remoteServerCapabilities({ mediaModels: { image: ' ' } })).toEqual({ + imageGeneration: false, + transcription: false, + voice: false, + }); + }); + + it('keeps private-LAN HTTP unauthenticated and rejects redirects', async () => { + const calls: Array<[RequestInfo | URL, RequestInit | undefined]> = []; + global.fetch = jest.fn(async (url, init) => { + calls.push([url, init]); + return jsonResponse({ data: [{ b64_json: 'image-bytes' }] }); + }) as typeof fetch; + + await expect( + remoteMediaRuntime.generateImage(server, { prompt: 'A quiet desk' }), + ).resolves.toEqual({ base64: 'image-bytes', url: undefined }); + + expect(calls[0]?.[0]).toBe('http://192.168.1.30:7878/v1/images/generations'); + expect(calls[0]?.[1]?.headers).toMatchObject({ 'Content-Type': 'application/json' }); + expect(calls[0]?.[1]?.headers).not.toHaveProperty('Authorization'); + expect(calls[0]?.[1]?.redirect).toBe('error'); + expect(JSON.parse(String(calls[0]?.[1]?.body))).toMatchObject({ + model: 'flux-schnell', + prompt: 'A quiet desk', + }); + expect(server).not.toHaveProperty('apiKey'); + }); + + it('sends a stored credential only to an HTTPS endpoint', async () => { + const calls: Array<[RequestInfo | URL, RequestInit | undefined]> = []; + global.fetch = jest.fn(async (url, init) => { + calls.push([url, init]); + return jsonResponse({ data: [{ b64_json: 'image-bytes' }] }); + }) as typeof fetch; + await remoteMediaRuntime.generateImage( + { ...server, endpoint: 'https://desktop.example.test' }, + { prompt: 'A quiet desk' }, + ); + expect(calls[0]?.[1]?.headers).toMatchObject({ Authorization: 'Bearer device-secret' }); + expect(calls[0]?.[1]?.redirect).toBe('error'); + }); + + it('uses the transcription endpoint and reports an empty server response', async () => { + global.fetch = jest.fn(async () => jsonResponse({})) as typeof fetch; + + await expect( + remoteMediaRuntime.transcribe(server, { fileUri: 'file:///recording.wav' }), + ).rejects.toThrow('Remote server returned no transcript'); + }); + + it('routes file transcription through the active server without a local Whisper model', async () => { + global.fetch = jest.fn(async () => jsonResponse({ text: 'Private meeting notes' })) as typeof fetch; + const store = useRemoteServerStore.getState(); + const serverId = store.addServer({ + name: server.name, + endpoint: server.endpoint, + providerType: server.providerType, + mediaModels: { transcription: 'whisper-large-v3' }, + }); + store.setActiveServerId(serverId); + + await expect(whisperService.transcribeFile('file:///recording.wav')).resolves.toBe( + 'Private meeting notes', + ); + }); + + it('cancels an in-flight voice request through AbortSignal', async () => { + global.fetch = jest.fn(() => new Promise(() => undefined)) as typeof fetch; + const controller = new AbortController(); + const pending = remoteMediaRuntime.synthesizeVoice( + server, + { text: 'Your summary is ready.' }, + { signal: controller.signal }, + ); + controller.abort(); + + await expect(pending).rejects.toThrow('Remote request cancelled'); + }); + + it('writes remote speech into the file-backed playback seam for the active Desktop', async () => { + const audio = Uint8Array.from([1, 2, 3, 4]).buffer; + global.fetch = jest.fn(async () => ({ + ...jsonResponse({}, 200), + headers: { get: () => 'audio/mpeg' }, + arrayBuffer: async () => audio, + } as unknown as Response)) as typeof fetch; + const store = useRemoteServerStore.getState(); + const id = store.addServer({ + name: 'Studio Mac', + endpoint: server.endpoint, + providerType: server.providerType, + mediaModels: { voice: 'kokoro' }, + }); + store.setActiveServerId(id); + + const active = activeRemoteVoiceServer(); + expect(active?.name).toBe('Studio Mac'); + await expect( + synthesizeRemoteVoiceFile({ + server: active!, + text: 'Your summary is ready.', + messageId: 'message:1', + signal: new AbortController().signal, + }), + ).resolves.toBe('/mock/caches/remote_voice/message_1.mp3'); + expect(RNFS.writeFile).toHaveBeenCalledWith( + '/mock/caches/remote_voice/message_1.mp3', + Buffer.from(audio).toString('base64'), + 'base64', + ); + }); +}); diff --git a/__tests__/unit/services/remoteServerManager.test.ts b/__tests__/unit/services/remoteServerManager.test.ts index 99856a3d9..d948d7a5d 100644 --- a/__tests__/unit/services/remoteServerManager.test.ts +++ b/__tests__/unit/services/remoteServerManager.test.ts @@ -5,7 +5,10 @@ */ import { remoteServerManager } from '../../../src/services/remoteServerManager'; -import { detectVisionCapability, detectToolCallingCapability } from '../../../src/services/remoteServerManagerUtils'; +import { + detectVisionCapability, + detectToolCallingCapability, +} from '../../../src/services/remoteServerManagerUtils'; import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; import { providerRegistry } from '../../../src/services/providers/registry'; import * as Keychain from 'react-native-keychain'; @@ -14,7 +17,9 @@ import * as Keychain from 'react-native-keychain'; jest.mock('../../../src/stores/remoteServerStore'); jest.mock('../../../src/services/providers/registry'); jest.mock('../../../src/services/providers/openAICompatibleProvider', () => ({ - createOpenAIProvider: jest.fn().mockReturnValue({ dispose: jest.fn().mockResolvedValue(undefined) }), + createOpenAIProvider: jest + .fn() + .mockReturnValue({ dispose: jest.fn().mockResolvedValue(undefined) }), OpenAICompatibleProvider: jest.fn(), })); jest.mock('react-native-keychain', () => ({ @@ -33,7 +38,12 @@ describe('remoteServerManager', () => { describe('addServer', () => { it('should add server without API key', async () => { - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434', createdAt: Date.now() }; + const mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + createdAt: Date.now(), + }; const mockAddServer = jest.fn().mockReturnValue('server-1'); const mockGetServerById = jest.fn().mockReturnValue(mockServer); @@ -42,7 +52,9 @@ describe('remoteServerManager', () => { addServer: mockAddServer, getServerById: mockGetServerById, }); - (providerRegistry.registerProvider as jest.Mock).mockReturnValue(undefined); + (providerRegistry.registerProvider as jest.Mock).mockReturnValue( + undefined, + ); (Keychain.getGenericPassword as jest.Mock).mockResolvedValue(null); const result = await remoteServerManager.addServer({ @@ -56,7 +68,12 @@ describe('remoteServerManager', () => { }); it('should add server with API key and store it', async () => { - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434', createdAt: Date.now() }; + const mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + createdAt: Date.now(), + }; const mockAddServer = jest.fn().mockReturnValue('server-1'); const mockGetServerById = jest.fn().mockReturnValue(mockServer); @@ -65,7 +82,9 @@ describe('remoteServerManager', () => { addServer: mockAddServer, getServerById: mockGetServerById, }); - (providerRegistry.registerProvider as jest.Mock).mockReturnValue(undefined); + (providerRegistry.registerProvider as jest.Mock).mockReturnValue( + undefined, + ); (Keychain.setGenericPassword as jest.Mock).mockResolvedValue(true); (Keychain.getGenericPassword as jest.Mock).mockResolvedValue(null); @@ -79,7 +98,9 @@ describe('remoteServerManager', () => { expect(Keychain.setGenericPassword).toHaveBeenCalledWith( 'server_server-1', 'secret-key', - expect.objectContaining({ service: expect.stringContaining('server-1') }) + expect.objectContaining({ + service: expect.stringContaining('server-1'), + }), ); expect(result).toEqual(mockServer); }); @@ -94,17 +115,24 @@ describe('remoteServerManager', () => { getServerById: mockGetServerById, }); - await expect(remoteServerManager.addServer({ - name: 'Test', - endpoint: 'http://localhost:11434', - providerType: 'openai-compatible', - })).rejects.toThrow('Failed to create server'); + await expect( + remoteServerManager.addServer({ + name: 'Test', + endpoint: 'http://localhost:11434', + providerType: 'openai-compatible', + }), + ).rejects.toThrow('Failed to create server'); }); }); describe('updateServer', () => { it('should update server without API key change', async () => { - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434', createdAt: Date.now() }; + const mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + createdAt: Date.now(), + }; const mockGetServerById = jest.fn().mockReturnValue(mockServer); const mockUpdateServer = jest.fn(); @@ -116,11 +144,18 @@ describe('remoteServerManager', () => { await remoteServerManager.updateServer('server-1', { name: 'Updated' }); - expect(mockUpdateServer).toHaveBeenCalledWith('server-1', { name: 'Updated' }); + expect(mockUpdateServer).toHaveBeenCalledWith('server-1', { + name: 'Updated', + }); }); it('should update server with new API key', async () => { - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434', createdAt: Date.now() }; + const mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + createdAt: Date.now(), + }; const mockGetServerById = jest.fn().mockReturnValue(mockServer); const mockUpdateServer = jest.fn(); @@ -138,7 +173,12 @@ describe('remoteServerManager', () => { }); it('should remove API key when set to empty string', async () => { - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434', createdAt: Date.now() }; + const mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + createdAt: Date.now(), + }; const mockGetServerById = jest.fn().mockReturnValue(mockServer); const mockUpdateServer = jest.fn(); @@ -159,8 +199,9 @@ describe('remoteServerManager', () => { getServerById: jest.fn().mockReturnValue(null), }); - await expect(remoteServerManager.updateServer('nonexistent', { name: 'Test' })) - .rejects.toThrow('Server not found'); + await expect( + remoteServerManager.updateServer('nonexistent', { name: 'Test' }), + ).rejects.toThrow('Server not found'); }); }); @@ -171,12 +212,16 @@ describe('remoteServerManager', () => { (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ removeServer: mockRemoveServer, }); - (providerRegistry.unregisterProvider as jest.Mock).mockReturnValue(undefined); + (providerRegistry.unregisterProvider as jest.Mock).mockReturnValue( + undefined, + ); (Keychain.resetGenericPassword as jest.Mock).mockResolvedValue(true); await remoteServerManager.removeServer('server-1'); - expect(providerRegistry.unregisterProvider).toHaveBeenCalledWith('server-1'); + expect(providerRegistry.unregisterProvider).toHaveBeenCalledWith( + 'server-1', + ); expect(Keychain.resetGenericPassword).toHaveBeenCalled(); expect(mockRemoveServer).toHaveBeenCalledWith('server-1'); }); @@ -202,18 +247,24 @@ describe('remoteServerManager', () => { expect(key).toBeNull(); }); - it('should return null on keychain error', async () => { - (Keychain.getGenericPassword as jest.Mock).mockRejectedValue(new Error('Keychain error')); - - const key = await remoteServerManager.getApiKey('server-1'); + it('should reject on keychain error', async () => { + (Keychain.getGenericPassword as jest.Mock).mockRejectedValue( + new Error('Keychain error'), + ); - expect(key).toBeNull(); + await expect(remoteServerManager.getApiKey('server-1')).rejects.toThrow( + 'Keychain error', + ); }); }); describe('getServerWithApiKey', () => { it('should return server with API key', async () => { - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434' }; + const mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + }; (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ getServerById: jest.fn().mockReturnValue(mockServer), }); @@ -232,7 +283,9 @@ describe('remoteServerManager', () => { getServerById: jest.fn().mockReturnValue(null), }); - const result = await remoteServerManager.getServerWithApiKey('nonexistent'); + const result = await remoteServerManager.getServerWithApiKey( + 'nonexistent', + ); expect(result).toBeNull(); }); @@ -259,11 +312,20 @@ describe('remoteServerManager', () => { getModelById: jest.fn().mockReturnValue(null), }); - await remoteServerManager.setActiveRemoteTextModel('server-123', 'llama2'); + await remoteServerManager.setActiveRemoteTextModel( + 'server-123', + 'llama2', + ); - expect(useRemoteServerStore.getState().setActiveServerId).toHaveBeenCalledWith('server-123'); - expect(useRemoteServerStore.getState().setActiveRemoteTextModelId).toHaveBeenCalledWith('llama2'); - expect(providerRegistry.setActiveProvider).toHaveBeenCalledWith('server-123'); + expect( + useRemoteServerStore.getState().setActiveServerId, + ).toHaveBeenCalledWith('server-123'); + expect( + useRemoteServerStore.getState().setActiveRemoteTextModelId, + ).toHaveBeenCalledWith('llama2'); + expect(providerRegistry.setActiveProvider).toHaveBeenCalledWith( + 'server-123', + ); expect(mockLoadModel).toHaveBeenCalledWith('llama2'); }); @@ -278,7 +340,7 @@ describe('remoteServerManager', () => { // Should not throw await expect( - remoteServerManager.setActiveRemoteTextModel('server-123', 'llama2') + remoteServerManager.setActiveRemoteTextModel('server-123', 'llama2'), ).resolves.not.toThrow(); }); }); @@ -302,10 +364,17 @@ describe('remoteServerManager', () => { getServerById: jest.fn().mockReturnValue(null), }); - await remoteServerManager.setActiveRemoteImageModel('server-123', 'llava'); + await remoteServerManager.setActiveRemoteImageModel( + 'server-123', + 'llava', + ); - expect(useRemoteServerStore.getState().setActiveServerId).toHaveBeenCalledWith('server-123'); - expect(useRemoteServerStore.getState().setActiveRemoteImageModelId).toHaveBeenCalledWith('llava'); + expect( + useRemoteServerStore.getState().setActiveServerId, + ).toHaveBeenCalledWith('server-123'); + expect( + useRemoteServerStore.getState().setActiveRemoteImageModelId, + ).toHaveBeenCalledWith('llava'); expect(mockLoadModel).toHaveBeenCalledWith('llava'); }); }); @@ -322,16 +391,21 @@ describe('remoteServerManager', () => { remoteServerManager.clearActiveRemoteModel(); - expect(useRemoteServerStore.getState().setActiveServerId).toHaveBeenCalledWith(null); - expect(useRemoteServerStore.getState().setActiveRemoteTextModelId).toHaveBeenCalledWith(null); - expect(useRemoteServerStore.getState().setActiveRemoteImageModelId).toHaveBeenCalledWith(null); + expect( + useRemoteServerStore.getState().setActiveServerId, + ).toHaveBeenCalledWith(null); + expect( + useRemoteServerStore.getState().setActiveRemoteTextModelId, + ).toHaveBeenCalledWith(null); + expect( + useRemoteServerStore.getState().setActiveRemoteImageModelId, + ).toHaveBeenCalledWith(null); expect(providerRegistry.setActiveProvider).toHaveBeenCalledWith('local'); }); }); describe('detectVisionCapability', () => { it('should detect vision models from model name', () => { - const visionModels = [ 'llava-v1.6-mistral-7b', 'gpt-4-vision-preview', @@ -359,7 +433,6 @@ describe('remoteServerManager', () => { describe('detectToolCallingCapability', () => { it('should detect tool-capable models from model name', () => { - const toolCapableModels = [ 'gpt-4-turbo', 'gpt-3.5-turbo', @@ -375,12 +448,8 @@ describe('remoteServerManager', () => { }); it('should return false for non-tool-capable models', () => { - // These should NOT match the tool capability patterns - const nonToolModels = [ - 'phi-2', - 'tinyllama', - ]; + const nonToolModels = ['phi-2', 'tinyllama']; nonToolModels.forEach(modelId => { expect(detectToolCallingCapability(modelId)).toBe(false); @@ -388,7 +457,6 @@ describe('remoteServerManager', () => { }); it('should detect models with tool/function keywords', () => { - expect(detectToolCallingCapability('llama-2-70b-tool')).toBe(true); expect(detectToolCallingCapability('mistral-function-call')).toBe(true); expect(detectToolCallingCapability('firefunction-v1')).toBe(true); @@ -399,7 +467,6 @@ describe('remoteServerManager', () => { describe('detectVisionCapability comprehensive patterns', () => { it('should detect all vision model patterns', () => { - const visionModels = [ 'llava-v1.6-mistral-7b', 'bakllava-7b', @@ -426,7 +493,6 @@ describe('remoteServerManager', () => { }); it('should return false for non-vision models', () => { - const nonVisionModels = [ 'llama-2-7b', 'mistral-7b-instruct', @@ -513,8 +579,12 @@ describe('remoteServerManager', () => { remoteServerManager.setActiveServer('server-1'); - expect(useRemoteServerStore.getState().setActiveServerId).toHaveBeenCalledWith('server-1'); - expect(providerRegistry.setActiveProvider).toHaveBeenCalledWith('server-1'); + expect( + useRemoteServerStore.getState().setActiveServerId, + ).toHaveBeenCalledWith('server-1'); + expect(providerRegistry.setActiveProvider).toHaveBeenCalledWith( + 'server-1', + ); }); it('should set to local when id is null', () => { @@ -525,7 +595,9 @@ describe('remoteServerManager', () => { remoteServerManager.setActiveServer(null); - expect(useRemoteServerStore.getState().setActiveServerId).toHaveBeenCalledWith(null); + expect( + useRemoteServerStore.getState().setActiveServerId, + ).toHaveBeenCalledWith(null); expect(providerRegistry.setActiveProvider).toHaveBeenCalledWith('local'); }); }); @@ -533,8 +605,16 @@ describe('remoteServerManager', () => { describe('testConnection', () => { it('should return store result as-is (capabilities come from server API, not name patterns)', async () => { const mockModels = [ - { id: 'llava-v1.6', name: 'LLaVA', capabilities: { supportsVision: true } }, - { id: 'llama-3-70b', name: 'Llama 3', capabilities: { supportsToolCalling: false } }, + { + id: 'llava-v1.6', + name: 'LLaVA', + capabilities: { supportsVision: true }, + }, + { + id: 'llama-3-70b', + name: 'Llama 3', + capabilities: { supportsToolCalling: false }, + }, ]; const mockTestConnection = jest.fn().mockResolvedValue({ success: true, @@ -599,9 +679,14 @@ describe('remoteServerManager', () => { testConnectionByEndpoint: mockTestConnectionByEndpoint, }); - const result = await remoteServerManager.testConnectionByEndpoint('http://localhost:11434'); + const result = await remoteServerManager.testConnectionByEndpoint( + 'http://localhost:11434', + ); - expect(mockTestConnectionByEndpoint).toHaveBeenCalledWith('http://localhost:11434', undefined); + expect(mockTestConnectionByEndpoint).toHaveBeenCalledWith( + 'http://localhost:11434', + undefined, + ); expect(result.success).toBe(true); }); @@ -614,9 +699,15 @@ describe('remoteServerManager', () => { testConnectionByEndpoint: mockTestConnectionByEndpoint, }); - await remoteServerManager.testConnectionByEndpoint('http://localhost:11434', 'api-key'); + await remoteServerManager.testConnectionByEndpoint( + 'http://localhost:11434', + 'api-key', + ); - expect(mockTestConnectionByEndpoint).toHaveBeenCalledWith('http://localhost:11434', 'api-key'); + expect(mockTestConnectionByEndpoint).toHaveBeenCalledWith( + 'http://localhost:11434', + 'api-key', + ); }); }); @@ -626,12 +717,17 @@ describe('remoteServerManager', () => { getServerById: jest.fn().mockReturnValue(null), }); - await expect(remoteServerManager.discoverModels('nonexistent')) - .rejects.toThrow('Server not found'); + await expect( + remoteServerManager.discoverModels('nonexistent'), + ).rejects.toThrow('Server not found'); }); it('should discover models from server', async () => { - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434' }; + const mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + }; const mockModels = [{ id: 'model-1', name: 'Model 1' }]; (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ @@ -646,7 +742,11 @@ describe('remoteServerManager', () => { }); it('should pass API key when discovering models', async () => { - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434' }; + const mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + }; const mockModels = [{ id: 'model-1', name: 'Model 1' }]; (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ @@ -674,12 +774,18 @@ describe('remoteServerManager', () => { getLoadedModelId: jest.fn().mockReturnValue('llama2'), isReady: jest.fn().mockResolvedValue(true), }; - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434' }; + const mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + }; (providerRegistry.getProvider as jest.Mock) .mockReturnValueOnce(null) // First call returns null .mockReturnValueOnce(mockProvider); // Second call returns provider after creation - (providerRegistry.registerProvider as jest.Mock).mockReturnValue(undefined); + (providerRegistry.registerProvider as jest.Mock).mockReturnValue( + undefined, + ); (providerRegistry.setActiveProvider as jest.Mock).mockReturnValue(true); (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ setActiveServerId: jest.fn(), @@ -706,12 +812,18 @@ describe('remoteServerManager', () => { isModelLoaded: jest.fn().mockReturnValue(true), isReady: jest.fn().mockResolvedValue(true), }; - const mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434' }; + const mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + }; (providerRegistry.getProvider as jest.Mock) .mockReturnValueOnce(null) .mockReturnValueOnce(mockProvider); - (providerRegistry.registerProvider as jest.Mock).mockReturnValue(undefined); + (providerRegistry.registerProvider as jest.Mock).mockReturnValue( + undefined, + ); (useRemoteServerStore.getState as jest.Mock).mockReturnValue({ setActiveServerId: jest.fn(), setActiveRemoteTextModelId: jest.fn(), @@ -727,7 +839,11 @@ describe('remoteServerManager', () => { }); it('should warn when provider cannot be created', async () => { - const _mockServer = { id: 'server-1', name: 'Test', endpoint: 'http://localhost:11434' }; + const _mockServer = { + id: 'server-1', + name: 'Test', + endpoint: 'http://localhost:11434', + }; const _mockLogger = { warn: jest.fn() }; jest.spyOn(console, 'warn').mockImplementation(() => {}); @@ -745,4 +861,4 @@ describe('remoteServerManager', () => { expect(providerRegistry.registerProvider).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/unit/services/remoteServerReconnect.test.ts b/__tests__/unit/services/remoteServerReconnect.test.ts new file mode 100644 index 000000000..45abc0bb4 --- /dev/null +++ b/__tests__/unit/services/remoteServerReconnect.test.ts @@ -0,0 +1,178 @@ +import { getIpAddress } from 'react-native-device-info'; +import * as Keychain from 'react-native-keychain'; +import { remoteServerManager } from '../../../src/services/remoteServerManager'; +import { useAppStore } from '../../../src/stores/appStore'; +import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; + +jest.mock('react-native-device-info', () => ({ + getIpAddress: jest.fn(), + isEmulator: jest.fn(() => Promise.resolve(false)), +})); + +const modelList = () => + new Response(JSON.stringify({ object: 'list', data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +describe('remote server reconnect', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + useRemoteServerStore.getState().clearAllServers(); + useAppStore.getState().updateSettings({ autoDiscoverRemoteModels: false }); + (getIpAddress as jest.Mock).mockResolvedValue('192.168.1.30'); + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + useRemoteServerStore.getState().clearAllServers(); + useAppStore.getState().updateSettings({ autoDiscoverRemoteModels: false }); + global.fetch = originalFetch; + }); + + it('keeps a reachable saved server when another discovered server uses the same port', async () => { + const endpointA = 'http://192.168.1.10:7878'; + const endpointB = 'http://192.168.1.20:7878'; + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = String(input); + if ( + url === `${endpointA}/v1/models` || + url === `${endpointB}/v1/models` + ) { + return Promise.resolve(modelList()); + } + return Promise.reject(new Error('no server')); + }); + const serverId = useRemoteServerStore.getState().addServer({ + name: 'Desktop A', + endpoint: endpointA, + providerType: 'openai-compatible', + }); + + const result = await remoteServerManager.scanAndReconcile(); + + expect( + useRemoteServerStore.getState().getServerById(serverId)?.endpoint, + ).toBe(endpointA); + expect(result).toEqual({ + moved: [], + found: [ + { + endpoint: endpointB, + type: 'gateway', + name: 'Off Grid AI Gateway (192.168.1.20)', + }, + ], + }); + }); + + it('does not move a credentialed server onto discovered private-LAN HTTP', async () => { + const oldEndpoint = 'https://desktop.example.test:7878'; + const discoveredEndpoint = 'http://192.168.1.20:7878'; + global.fetch = jest.fn((input: RequestInfo | URL) => + String(input) === `${discoveredEndpoint}/v1/models` + ? Promise.resolve(modelList()) + : Promise.reject(new Error('no server')), + ); + const serverId = useRemoteServerStore.getState().addServer({ + name: 'Credentialed Desktop', + endpoint: oldEndpoint, + providerType: 'openai-compatible', + }); + (Keychain.getGenericPassword as jest.Mock).mockResolvedValue({ + username: `server_${serverId}`, + password: 'secret', // NOSONAR - test boundary value, not a real credential + }); + + const result = await remoteServerManager.scanAndReconcile(); + + expect( + useRemoteServerStore.getState().getServerById(serverId)?.endpoint, + ).toBe(oldEndpoint); + expect(result.moved).toEqual([]); + expect(result.found).toEqual([ + expect.objectContaining({ endpoint: discoveredEndpoint }), + ]); + }); + + it('keeps a same-port discovery unclaimed when Keychain lookup fails', async () => { + const oldEndpoint = 'https://desktop.example.test:7878'; + const discoveredEndpoint = 'http://192.168.1.20:7878'; + global.fetch = jest.fn((input: RequestInfo | URL) => + String(input) === `${discoveredEndpoint}/v1/models` + ? Promise.resolve(modelList()) + : Promise.reject(new Error('no server')), + ); + const serverId = useRemoteServerStore.getState().addServer({ + name: 'Desktop with unavailable credentials', + endpoint: oldEndpoint, + providerType: 'openai-compatible', + }); + (Keychain.getGenericPassword as jest.Mock).mockRejectedValueOnce( + new Error('Keychain unavailable'), + ); + + const result = await remoteServerManager.scanAndReconcile(); + + expect( + useRemoteServerStore.getState().getServerById(serverId)?.endpoint, + ).toBe(oldEndpoint); + expect(result.moved).toEqual([]); + expect(result.found).toEqual([ + expect.objectContaining({ endpoint: discoveredEndpoint }), + ]); + }); + + it('reconciles a unique same-port discovery after Keychain confirms no credential', async () => { + const oldEndpoint = 'http://192.168.1.10:7878'; + const discoveredEndpoint = 'http://192.168.1.20:7878'; + global.fetch = jest.fn((input: RequestInfo | URL) => + String(input) === `${discoveredEndpoint}/v1/models` + ? Promise.resolve(modelList()) + : Promise.reject(new Error('no server')), + ); + const serverId = useRemoteServerStore.getState().addServer({ + name: 'Uncredentialed Desktop', + endpoint: oldEndpoint, + providerType: 'openai-compatible', + }); + + const result = await remoteServerManager.scanAndReconcile(); + + expect( + useRemoteServerStore.getState().getServerById(serverId)?.endpoint, + ).toBe(discoveredEndpoint); + expect(result.moved).toEqual([serverId]); + expect(result.found).toEqual([]); + }); + + it('scans when auto-discovery is enabled and the active server is reachable', async () => { + const endpoint = 'http://192.168.1.10:7878'; + global.fetch = jest.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url === `${endpoint}/v1/models`) return Promise.resolve(modelList()); + return Promise.reject(new Error('no server')); + }); + const store = useRemoteServerStore.getState(); + const serverId = store.addServer({ + name: 'Desktop', + endpoint, + providerType: 'openai-compatible', + }); + store.setActiveServerId(serverId); + useAppStore.getState().updateSettings({ autoDiscoverRemoteModels: true }); + + await remoteServerManager.recoverActiveConnection(); + + expect( + useRemoteServerStore.getState().serverHealth[serverId]?.isHealthy, + ).toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + 'http://192.168.1.2:7878/v1/models', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); +}); diff --git a/__tests__/unit/services/remoteToolCapabilityPropagation.test.ts b/__tests__/unit/services/remoteToolCapabilityPropagation.test.ts new file mode 100644 index 000000000..2699a90b6 --- /dev/null +++ b/__tests__/unit/services/remoteToolCapabilityPropagation.test.ts @@ -0,0 +1,52 @@ +import { setActiveRemoteTextModelImpl } from '../../../src/services/remoteServerManagerUtils'; +import { OpenAICompatibleProvider } from '../../../src/services/providers/openAICompatibleProvider'; +import { providerRegistry } from '../../../src/services/providers/registry'; +import { + REMOTE_TOOLS_UNAVAILABLE, + remoteToolCapabilityIssue, +} from '../../../src/services/toolCapabilityPreflight'; +import { useRemoteServerStore } from '../../../src/stores/remoteServerStore'; + +describe('selected remote model tool capability', () => { + beforeEach(() => { + providerRegistry.clear(); + useRemoteServerStore.getState().clearAllServers(); + }); + + afterEach(() => { + providerRegistry.clear(); + useRemoteServerStore.getState().clearAllServers(); + }); + + it('propagates an unsupported selected model and blocks the tool loop', async () => { + const serverId = useRemoteServerStore.getState().addServer({ + name: 'Private Desktop', + endpoint: 'http://192.168.1.30:7878', + providerType: 'openai-compatible', + }); + useRemoteServerStore.getState().setDiscoveredModels(serverId, [ + { + id: 'vision-without-tools', + name: 'Vision without tools', + serverId, + capabilities: { + supportsVision: true, + supportsToolCalling: false, + supportsThinking: false, + }, + lastUpdated: '2026-08-30T00:00:00.000Z', + }, + ]); + const provider = new OpenAICompatibleProvider(serverId, { + endpoint: 'http://192.168.1.30:7878', + modelId: '', + }); + providerRegistry.registerProvider(serverId, provider); + + await setActiveRemoteTextModelImpl(serverId, 'vision-without-tools'); + + expect(provider.capabilities.supportsToolCalling).toBe(false); + expect(remoteToolCapabilityIssue(1)).toBe(REMOTE_TOOLS_UNAVAILABLE); + expect(remoteToolCapabilityIssue(0)).toBeUndefined(); + }); +}); diff --git a/__tests__/unit/services/remoteTransportPolicy.test.ts b/__tests__/unit/services/remoteTransportPolicy.test.ts new file mode 100644 index 000000000..a8800888d --- /dev/null +++ b/__tests__/unit/services/remoteTransportPolicy.test.ts @@ -0,0 +1,98 @@ +import { + PUBLIC_HTTP_REMOTE_ERROR, + canReconcileCredentialedEndpoint, + isCredentialTransportDowngrade, + remoteAuthorizationHeaders, + validateRemoteEndpoint, +} from '../../../src/services/remoteTransportPolicy'; +import { fetchModelsFromServer } from '../../../src/stores/remoteServerHelpers'; + +describe('remote transport policy', () => { + const originalFetch = global.fetch; + afterEach(() => { + global.fetch = originalFetch; + }); + it('allows unauthenticated private-LAN HTTP but never sends its bearer credential', () => { + expect( + remoteAuthorizationHeaders('http://192.168.1.30:7878', 'secret'), + ).toEqual({}); + expect( + remoteAuthorizationHeaders('http://desktop.local:7878', 'secret'), + ).toEqual({}); + expect(remoteAuthorizationHeaders('http://desktop:7878', 'secret')).toEqual( + {}, + ); + }); + + it('allows bearer credentials only on HTTPS', () => { + expect( + remoteAuthorizationHeaders('https://desktop.example.test', 'secret'), + ).toEqual({ + Authorization: 'Bearer secret', + }); + }); + + it('rejects public cleartext endpoints before a request starts', () => { + expect(() => validateRemoteEndpoint('http://example.test:7878')).toThrow( + PUBLIC_HTTP_REMOTE_ERROR, + ); + expect(() => + validateRemoteEndpoint('http://192.168.attacker.example:7878'), + ).toThrow(PUBLIC_HTTP_REMOTE_ERROR); + expect(() => + validateRemoteEndpoint('http://10.attacker.example:7878'), + ).toThrow(PUBLIC_HTTP_REMOTE_ERROR); + }); + + it('does not reconcile a credentialed server onto a discovered HTTP endpoint', () => { + expect( + canReconcileCredentialedEndpoint('http://192.168.1.31:7878', true), + ).toBe(false); + expect( + canReconcileCredentialedEndpoint('http://192.168.1.31:7878', false), + ).toBe(true); + expect( + canReconcileCredentialedEndpoint('https://desktop.local:7878', true), + ).toBe(true); + }); + + it('detects an HTTPS-to-HTTP response downgrade only for credentialed requests', () => { + expect( + isCredentialTransportDowngrade( + 'https://desktop.example.test/v1/chat', + 'http://192.168.1.30:7878/v1/chat', + true, + ), + ).toBe(true); + expect( + isCredentialTransportDowngrade( + 'https://desktop.example.test/v1/chat', + 'http://192.168.1.30:7878/v1/chat', + false, + ), + ).toBe(false); + }); + + it('keeps model discovery on private-LAN HTTP unauthenticated', async () => { + const calls: Array = []; + global.fetch = jest.fn(async (_url, init) => { + calls.push(init); + return { + ok: true, + json: async () => ({ object: 'list', data: [] }), + } as Response; + }) as typeof fetch; + + await fetchModelsFromServer({ + id: 'desktop', + name: 'Desktop', + endpoint: 'http://192.168.1.30:7878', + providerType: 'openai-compatible', + apiKey: 'secret', + createdAt: '2026-08-30', + }); + + expect(calls[0]?.headers).not.toHaveProperty('Authorization'); + expect(calls[0]?.redirect).toBe('error'); + }); +}); diff --git a/__tests__/unit/services/tools/EmailCalendarExtension.test.ts b/__tests__/unit/services/tools/EmailCalendarExtension.test.ts index b8ed71830..d6b915831 100644 --- a/__tests__/unit/services/tools/EmailCalendarExtension.test.ts +++ b/__tests__/unit/services/tools/EmailCalendarExtension.test.ts @@ -32,19 +32,30 @@ jest.mock('react-native-calendar-events', () => ({ // out in the open-core CI. Load it dynamically via a computed path (so tsc does // not try to resolve the absent module) and skip the suite when it is missing. // jest hoists the jest.mock calls above this, so the mocks are already registered. -function loadProExtension(): ToolExtension | null { +interface LoadedProExtension { + extension: ToolExtension; + setEntitlementActive(active: boolean): void; +} + +function loadProExtension(): LoadedProExtension | null { const proPath = ['..', '..', '..', '..', 'pro', 'tools', 'EmailCalendarExtension'].join('/'); try { - - return require(proPath).EmailCalendarExtension as ToolExtension; + const module = require(proPath) as { + EmailCalendarExtension: ToolExtension; + setEmailCalendarEntitlementActive(active: boolean): void; + }; + return { + extension: module.EmailCalendarExtension, + setEntitlementActive: module.setEmailCalendarEntitlementActive, + }; } catch { return null; } } -const proExtension = loadProExtension(); -const EmailCalendarExtension = proExtension ?? ({} as ToolExtension); -const describeIfPro = proExtension ? describe : describe.skip; +const loadedProExtension = loadProExtension(); +const EmailCalendarExtension = loadedProExtension?.extension ?? ({} as ToolExtension); +const describeIfPro = loadedProExtension ? describe : describe.skip; const mockOpenURL = jest.spyOn(Linking, 'openURL'); @@ -54,6 +65,7 @@ function call(name: string, args: Record = {}): ToolCall { describeIfPro('EmailCalendarExtension', () => { beforeEach(() => { + loadedProExtension?.setEntitlementActive(true); mockEnabledTools = []; mockOpenURL.mockReset().mockResolvedValue(undefined as never); mockSaveEvent.mockReset().mockResolvedValue('evt-1'); @@ -61,6 +73,10 @@ describeIfPro('EmailCalendarExtension', () => { mockFetchAllEvents.mockReset().mockResolvedValue([]); }); + afterAll(() => { + loadedProExtension?.setEntitlementActive(false); + }); + describe('definitions and gating', () => { it('advertises the three tools to the main picker', () => { const ids = EmailCalendarExtension.getToolDefinitions!().map(t => t.id); diff --git a/__tests__/unit/services/whisperModelDownloads.test.ts b/__tests__/unit/services/whisperModelDownloads.test.ts new file mode 100644 index 000000000..6668337b6 --- /dev/null +++ b/__tests__/unit/services/whisperModelDownloads.test.ts @@ -0,0 +1,266 @@ +import RNFS from 'react-native-fs'; +import { backgroundDownloadService } from '../../../src/services/backgroundDownloadService'; +import { WhisperModelDownloads } from '../../../src/services/whisperModelDownloads'; +import * as whisperModelFiles from '../../../src/services/whisperModelFiles'; + +jest.mock('../../../src/services/backgroundDownloadService', () => ({ + backgroundDownloadService: { + downloadFileTo: jest.fn(), + cancelDownload: jest.fn(async () => {}), + }, +})); + +const mockAdd = jest.fn(); +const mockRemove = jest.fn(); +const mockRetryEntry = jest.fn(); +jest.mock('../../../src/stores/downloadStore', () => ({ + useDownloadStore: { + getState: () => ({ + add: mockAdd, + remove: mockRemove, + retryEntry: mockRetryEntry, + }), + }, +})); + +function deferred(): { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +} { + let resolve = () => {}; + let reject = (_error: Error) => {}; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +function deferredDownloadId(): { + promise: Promise; + resolve: (id: string) => void; + reject: (error: Error) => void; +} { + let resolve = (_id: string) => {}; + let reject = (_error: Error) => {}; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} + +describe('WhisperModelDownloads concurrent ownership', () => { + beforeEach(() => jest.clearAllMocks()); + afterEach(() => jest.restoreAllMocks()); + + it('cancels the deleted model without disturbing another active download', async () => { + const first = deferred(); + const second = deferred(); + jest.spyOn(whisperModelFiles, 'ensureModelsDirExists').mockResolvedValue(); + jest.spyOn(whisperModelFiles, 'validateModelFile').mockResolvedValue(); + jest.spyOn(RNFS, 'exists').mockResolvedValue(false); + const downloadFileTo = + backgroundDownloadService.downloadFileTo as jest.Mock; + downloadFileTo + .mockReturnValueOnce({ + downloadIdPromise: Promise.resolve(11), + promise: first.promise, + }) + .mockReturnValueOnce({ + downloadIdPromise: Promise.resolve(22), + promise: second.promise, + }); + + const downloads = new WhisperModelDownloads(); + const tiny = downloads.downloadModel('tiny.en'); + const base = downloads.downloadModel('base.en'); + await new Promise(resolve => setImmediate(resolve)); + + await downloads.deleteModel('tiny.en'); + expect(backgroundDownloadService.cancelDownload).toHaveBeenCalledWith(11); + expect(backgroundDownloadService.cancelDownload).not.toHaveBeenCalledWith( + 22, + ); + + first.resolve(); + second.resolve(); + await Promise.all([tiny, base]); + await downloads.deleteModel('base.en'); + expect(backgroundDownloadService.cancelDownload).not.toHaveBeenCalledWith( + 22, + ); + }); + + it('does not let an older same-model completion erase the newer download owner', async () => { + const older = deferred(); + const newer = deferred(); + jest.spyOn(whisperModelFiles, 'ensureModelsDirExists').mockResolvedValue(); + jest.spyOn(whisperModelFiles, 'validateModelFile').mockResolvedValue(); + jest.spyOn(RNFS, 'exists').mockResolvedValue(false); + const downloadFileTo = + backgroundDownloadService.downloadFileTo as jest.Mock; + downloadFileTo + .mockReturnValueOnce({ + downloadIdPromise: Promise.resolve(31), + promise: older.promise, + }) + .mockReturnValueOnce({ + downloadIdPromise: Promise.resolve(32), + promise: newer.promise, + }); + + const downloads = new WhisperModelDownloads(); + const first = downloads.downloadModel('tiny.en'); + const second = downloads.downloadModel('tiny.en'); + await new Promise(resolve => setImmediate(resolve)); + older.resolve(); + await first; + + await downloads.deleteModel('tiny.en'); + expect(backgroundDownloadService.cancelDownload).toHaveBeenCalledWith(32); + expect(backgroundDownloadService.cancelDownload).not.toHaveBeenCalledWith( + 31, + ); + newer.resolve(); + await second; + }); + + it('cancels a queued model deleted before its native download id resolves', async () => { + const id = deferredDownloadId(); + const file = deferred(); + jest.spyOn(whisperModelFiles, 'ensureModelsDirExists').mockResolvedValue(); + jest.spyOn(whisperModelFiles, 'validateModelFile').mockResolvedValue(); + jest.spyOn(RNFS, 'exists').mockResolvedValue(false); + (backgroundDownloadService.downloadFileTo as jest.Mock).mockReturnValueOnce( + { downloadIdPromise: id.promise, promise: file.promise }, + ); + + const downloads = new WhisperModelDownloads(); + const downloading = downloads.downloadModel('tiny.en'); + await new Promise(resolve => setImmediate(resolve)); + + const cancelled = new Error('Download cancelled') as Error & { + cancelled?: boolean; + }; + cancelled.cancelled = true; + ( + backgroundDownloadService.cancelDownload as jest.Mock + ).mockImplementationOnce(async downloadId => { + expect(downloadId).toBe('queued:whisper-tiny.en/ggml-tiny.en.bin'); + id.reject(cancelled); + }); + const deleting = downloads.deleteModel('tiny.en'); + await deleting; + + expect(backgroundDownloadService.cancelDownload).toHaveBeenCalledWith( + 'queued:whisper-tiny.en/ggml-tiny.en.bin', + ); + await expect(downloading).rejects.toMatchObject({ cancelled: true }); + expect(mockRemove).toHaveBeenCalledWith('whisper-tiny.en/ggml-tiny.en.bin'); + }); + + it('keeps replacement ownership when an older queued delete settles', async () => { + const olderId = deferredDownloadId(); + const olderFile = deferred(); + const newerFile = deferred(); + jest.spyOn(whisperModelFiles, 'ensureModelsDirExists').mockResolvedValue(); + jest.spyOn(whisperModelFiles, 'validateModelFile').mockResolvedValue(); + jest.spyOn(RNFS, 'exists').mockResolvedValue(false); + const downloadFileTo = + backgroundDownloadService.downloadFileTo as jest.Mock; + downloadFileTo + .mockReturnValueOnce({ + downloadIdPromise: olderId.promise, + promise: olderFile.promise, + }) + .mockReturnValueOnce({ + downloadIdPromise: Promise.resolve('replacement-52'), + promise: newerFile.promise, + }); + + const downloads = new WhisperModelDownloads(); + const older = downloads.downloadModel('tiny.en'); + await new Promise(resolve => setImmediate(resolve)); + const staleDelete = downloads.deleteModel('tiny.en'); + const replacement = downloads.downloadModel('tiny.en'); + await new Promise(resolve => setImmediate(resolve)); + + olderId.resolve('older-51'); + await staleDelete; + expect(backgroundDownloadService.cancelDownload).toHaveBeenCalledWith( + 'older-51', + ); + expect(backgroundDownloadService.cancelDownload).not.toHaveBeenCalledWith( + 'replacement-52', + ); + + olderFile.resolve(); + await older; + expect(mockRemove).not.toHaveBeenCalled(); + + await downloads.deleteModel('tiny.en'); + expect(backgroundDownloadService.cancelDownload).toHaveBeenCalledWith( + 'replacement-52', + ); + newerFile.resolve(); + await replacement; + }); + + it('does not let an older failed owner unlink a replacement file', async () => { + const olderFile = deferred(); + const replacementFile = deferred(); + jest.spyOn(whisperModelFiles, 'ensureModelsDirExists').mockResolvedValue(); + jest.spyOn(whisperModelFiles, 'validateModelFile').mockResolvedValue(); + jest.spyOn(RNFS, 'exists').mockResolvedValue(false); + const unlink = jest.spyOn(RNFS, 'unlink').mockResolvedValue(); + const downloadFileTo = + backgroundDownloadService.downloadFileTo as jest.Mock; + downloadFileTo + .mockReturnValueOnce({ + downloadIdPromise: Promise.resolve('older-61'), + promise: olderFile.promise, + }) + .mockReturnValueOnce({ + downloadIdPromise: Promise.resolve('replacement-62'), + promise: replacementFile.promise, + }); + + const downloads = new WhisperModelDownloads(); + const older = downloads.downloadModel('tiny.en'); + const replacement = downloads.downloadModel('tiny.en'); + await new Promise(resolve => setImmediate(resolve)); + + olderFile.reject(new Error('Older download failed')); + await expect(older).rejects.toThrow('Older download failed'); + expect(unlink).not.toHaveBeenCalled(); + + replacementFile.resolve(); + await replacement; + }); + + it('removes a failed partial file when the failing download still owns it', async () => { + const file = deferred(); + jest.spyOn(whisperModelFiles, 'ensureModelsDirExists').mockResolvedValue(); + jest.spyOn(whisperModelFiles, 'validateModelFile').mockResolvedValue(); + jest.spyOn(RNFS, 'exists').mockResolvedValue(false); + const unlink = jest.spyOn(RNFS, 'unlink').mockResolvedValue(); + (backgroundDownloadService.downloadFileTo as jest.Mock).mockReturnValueOnce( + { + downloadIdPromise: Promise.resolve('current-71'), + promise: file.promise, + }, + ); + + const downloads = new WhisperModelDownloads(); + const downloading = downloads.downloadModel('tiny.en'); + await new Promise(resolve => setImmediate(resolve)); + file.reject(new Error('Current download failed')); + + await expect(downloading).rejects.toThrow('Current download failed'); + expect(unlink).toHaveBeenCalledWith( + whisperModelFiles.getModelPath('tiny.en'), + ); + }); +}); diff --git a/__tests__/unit/services/whisperService.test.ts b/__tests__/unit/services/whisperService.test.ts index 9e7197968..68e11fbce 100644 --- a/__tests__/unit/services/whisperService.test.ts +++ b/__tests__/unit/services/whisperService.test.ts @@ -8,14 +8,20 @@ import { initWhisper } from 'whisper.rn'; import { Platform, PermissionsAndroid } from 'react-native'; import RNFS from 'react-native-fs'; -import { whisperService, WHISPER_MODELS } from '../../../src/services/whisperService'; +import { + whisperService, + WHISPER_MODELS, +} from '../../../src/services/whisperService'; import { backgroundDownloadService } from '../../../src/services/backgroundDownloadService'; import { audioSessionManager } from '../../../src/services/audioSessionManager'; +import { audioRecorderService } from '../../../src/services/audioRecorderService'; import { AudioManager } from 'react-native-audio-api'; // The realtime permission path drives audioSessionManager, which calls these. -const mockSetAudioSessionOptions = AudioManager.setAudioSessionOptions as jest.Mock; -const mockSetAudioSessionActivity = AudioManager.setAudioSessionActivity as jest.Mock; +const mockSetAudioSessionOptions = + AudioManager.setAudioSessionOptions as jest.Mock; +const mockSetAudioSessionActivity = + AudioManager.setAudioSessionActivity as jest.Mock; jest.mock('../../../src/services/backgroundDownloadService', () => ({ backgroundDownloadService: { @@ -39,15 +45,22 @@ jest.mock('../../../src/stores/downloadStore', () => ({ }, })); -const mockedBDS = backgroundDownloadService as jest.Mocked; +const mockedBDS = backgroundDownloadService as jest.Mocked< + typeof backgroundDownloadService +>; const mockedRNFS = RNFS as jest.Mocked; -const mockedInitWhisper = initWhisper as jest.MockedFunction; +const mockedInitWhisper = initWhisper as jest.MockedFunction< + typeof initWhisper +>; - /** Mock RNFS to report a valid model file (exists + large enough) */ +/** Mock RNFS to report a valid model file (exists + large enough) */ const mockValidModelFile = () => { mockedRNFS.exists.mockResolvedValue(true); - mockedRNFS.stat.mockResolvedValue({ size: 75 * 1024 * 1024, isFile: () => true } as any); + mockedRNFS.stat.mockResolvedValue({ + size: 75 * 1024 * 1024, + isFile: () => true, + } as any); }; describe('WhisperService', () => { @@ -82,14 +95,16 @@ describe('WhisperService', () => { // ======================================================================== describe('getModelsDir', () => { it('returns path under DocumentDirectoryPath', () => { - expect(whisperService.getModelsDir()).toBe('/mock/documents/whisper-models'); + expect(whisperService.getModelsDir()).toBe( + '/mock/documents/whisper-models', + ); }); }); describe('getModelPath', () => { it('returns correct path for a model ID', () => { expect(whisperService.getModelPath('tiny.en')).toBe( - '/mock/documents/whisper-models/ggml-tiny.en.bin' + '/mock/documents/whisper-models/ggml-tiny.en.bin', ); }); }); @@ -114,7 +129,9 @@ describe('WhisperService', () => { // ======================================================================== describe('downloadModel', () => { it('throws for unknown model ID', async () => { - await expect(whisperService.downloadModel('nonexistent')).rejects.toThrow('Unknown model'); + await expect(whisperService.downloadModel('nonexistent')).rejects.toThrow( + 'Unknown model', + ); }); it('returns existing path if already downloaded', async () => { @@ -128,10 +145,13 @@ describe('WhisperService', () => { it('downloads via backgroundDownloadService when not present', async () => { mockedRNFS.exists - .mockResolvedValueOnce(true) // dir exists + .mockResolvedValueOnce(true) // dir exists .mockResolvedValueOnce(false) // model not yet downloaded .mockResolvedValueOnce(true); // validateModelFile: file exists - mockedRNFS.stat.mockResolvedValueOnce({ size: 75 * 1024 * 1024, isFile: () => true } as any); + mockedRNFS.stat.mockResolvedValueOnce({ + size: 75 * 1024 * 1024, + isFile: () => true, + } as any); mockedBDS.downloadFileTo.mockReturnValue({ downloadId: 1, @@ -141,21 +161,29 @@ describe('WhisperService', () => { const result = await whisperService.downloadModel('tiny.en'); - expect(mockedBDS.downloadFileTo).toHaveBeenCalledWith(expect.objectContaining({ - // modelType 'stt' files the in-progress download under Voice in the - // Download Manager (without it the entry defaulted to 'text'). - params: expect.objectContaining({ url: WHISPER_MODELS[0].url, modelType: 'stt' }), - destPath: '/mock/documents/whisper-models/ggml-tiny.en.bin', - })); + expect(mockedBDS.downloadFileTo).toHaveBeenCalledWith( + expect.objectContaining({ + // modelType 'stt' files the in-progress download under Voice in the + // Download Manager (without it the entry defaulted to 'text'). + params: expect.objectContaining({ + url: WHISPER_MODELS[0].url, + modelType: 'stt', + }), + destPath: '/mock/documents/whisper-models/ggml-tiny.en.bin', + }), + ); expect(result).toBe('/mock/documents/whisper-models/ggml-tiny.en.bin'); }); it('calls progress callback', async () => { mockedRNFS.exists - .mockResolvedValueOnce(true) // dir exists + .mockResolvedValueOnce(true) // dir exists .mockResolvedValueOnce(false) // model doesn't exist .mockResolvedValueOnce(true); // validateModelFile: file exists - mockedRNFS.stat.mockResolvedValueOnce({ size: 75 * 1024 * 1024, isFile: () => true } as any); + mockedRNFS.stat.mockResolvedValueOnce({ + size: 75 * 1024 * 1024, + isFile: () => true, + } as any); let capturedOnProgress: ((b: number, t: number) => void) | undefined; mockedBDS.downloadFileTo.mockImplementation((opts: any) => { @@ -178,7 +206,7 @@ describe('WhisperService', () => { it('cleans up partial file and rethrows when download fails', async () => { mockedRNFS.exists - .mockResolvedValueOnce(true) // dir exists + .mockResolvedValueOnce(true) // dir exists .mockResolvedValueOnce(false); // model not yet downloaded mockedRNFS.unlink.mockResolvedValue(undefined as any); @@ -188,16 +216,23 @@ describe('WhisperService', () => { promise: Promise.reject(new Error('network_lost')), } as any); - await expect(whisperService.downloadModel('tiny.en')).rejects.toThrow('network_lost'); - expect(RNFS.unlink).toHaveBeenCalledWith('/mock/documents/whisper-models/ggml-tiny.en.bin'); + await expect(whisperService.downloadModel('tiny.en')).rejects.toThrow( + 'network_lost', + ); + expect(RNFS.unlink).toHaveBeenCalledWith( + '/mock/documents/whisper-models/ggml-tiny.en.bin', + ); }); it('registers the in-flight download in the download store so it shows live, then clears it on completion', async () => { mockedRNFS.exists - .mockResolvedValueOnce(true) // dir exists + .mockResolvedValueOnce(true) // dir exists .mockResolvedValueOnce(false) // model not yet downloaded .mockResolvedValueOnce(true); // validateModelFile: file exists - mockedRNFS.stat.mockResolvedValueOnce({ size: 75 * 1024 * 1024, isFile: () => true } as any); + mockedRNFS.stat.mockResolvedValueOnce({ + size: 75 * 1024 * 1024, + isFile: () => true, + } as any); mockedBDS.downloadFileTo.mockReturnValue({ downloadId: 7, downloadIdPromise: Promise.resolve(7), @@ -210,24 +245,31 @@ describe('WhisperService', () => { // keyed by whisper-/ and filed under Voice via modelType 'stt', // so a queued STT download shows as "Queued" in the same canonical store the // Text/Image cards read (not "0%"). This is the single-source-of-truth path. - expect(mockDownloadStoreAdd).toHaveBeenCalledWith(expect.objectContaining({ - modelKey: 'whisper-tiny.en/ggml-tiny.en.bin', - downloadId: 'queued:whisper-tiny.en/ggml-tiny.en.bin', - modelId: 'whisper-tiny.en', - fileName: 'ggml-tiny.en.bin', - modelType: 'stt', - status: 'pending', - })); + expect(mockDownloadStoreAdd).toHaveBeenCalledWith( + expect.objectContaining({ + modelKey: 'whisper-tiny.en/ggml-tiny.en.bin', + downloadId: 'queued:whisper-tiny.en/ggml-tiny.en.bin', + modelId: 'whisper-tiny.en', + fileName: 'ggml-tiny.en.bin', + modelType: 'stt', + status: 'pending', + }), + ); // Once a slot opens and the native download starts, the placeholder is // reconciled to the real downloadId so progress events route to it. - expect(mockDownloadStoreRetryEntry).toHaveBeenCalledWith('whisper-tiny.en/ggml-tiny.en.bin', 7); + expect(mockDownloadStoreRetryEntry).toHaveBeenCalledWith( + 'whisper-tiny.en/ggml-tiny.en.bin', + 7, + ); // Cleared on success — completed STT models are listed from disk instead. - expect(mockDownloadStoreRemove).toHaveBeenCalledWith('whisper-tiny.en/ggml-tiny.en.bin'); + expect(mockDownloadStoreRemove).toHaveBeenCalledWith( + 'whisper-tiny.en/ggml-tiny.en.bin', + ); }); it('clears the download store entry even when the download fails', async () => { mockedRNFS.exists - .mockResolvedValueOnce(true) // dir exists + .mockResolvedValueOnce(true) // dir exists .mockResolvedValueOnce(false); // model not yet downloaded mockedRNFS.unlink.mockResolvedValue(undefined as any); mockedBDS.downloadFileTo.mockReturnValue({ @@ -236,10 +278,14 @@ describe('WhisperService', () => { promise: Promise.reject(new Error('network_lost')), } as any); - await expect(whisperService.downloadModel('tiny.en')).rejects.toThrow('network_lost'); + await expect(whisperService.downloadModel('tiny.en')).rejects.toThrow( + 'network_lost', + ); expect(mockDownloadStoreAdd).toHaveBeenCalled(); - expect(mockDownloadStoreRemove).toHaveBeenCalledWith('whisper-tiny.en/ggml-tiny.en.bin'); + expect(mockDownloadStoreRemove).toHaveBeenCalledWith( + 'whisper-tiny.en/ggml-tiny.en.bin', + ); }); }); @@ -252,7 +298,9 @@ describe('WhisperService', () => { await whisperService.deleteModel('tiny.en'); - expect(RNFS.unlink).toHaveBeenCalledWith('/mock/documents/whisper-models/ggml-tiny.en.bin'); + expect(RNFS.unlink).toHaveBeenCalledWith( + '/mock/documents/whisper-models/ggml-tiny.en.bin', + ); }); it('does nothing when file does not exist', async () => { @@ -269,29 +317,43 @@ describe('WhisperService', () => { // ======================================================================== describe('validateModelFile', () => { it('throws when path is empty', async () => { - await expect(whisperService.validateModelFile('')).rejects.toThrow('empty or undefined'); + await expect(whisperService.validateModelFile('')).rejects.toThrow( + 'empty or undefined', + ); }); it('throws when file does not exist', async () => { mockedRNFS.exists.mockResolvedValue(false); - await expect(whisperService.validateModelFile('/missing/model.bin')).rejects.toThrow('not found'); + await expect( + whisperService.validateModelFile('/missing/model.bin'), + ).rejects.toThrow('not found'); }); it('throws and deletes file when file is too small (corrupted)', async () => { mockedRNFS.exists.mockResolvedValue(true); - mockedRNFS.stat.mockResolvedValue({ size: 1000, isFile: () => true } as any); + mockedRNFS.stat.mockResolvedValue({ + size: 1000, + isFile: () => true, + } as any); mockedRNFS.unlink.mockResolvedValue(undefined as any); - await expect(whisperService.validateModelFile('/path/model.bin')).rejects.toThrow('too small'); + await expect( + whisperService.validateModelFile('/path/model.bin'), + ).rejects.toThrow('too small'); expect(RNFS.unlink).toHaveBeenCalledWith('/path/model.bin'); }); it('passes for valid file with sufficient size', async () => { mockedRNFS.exists.mockResolvedValue(true); - mockedRNFS.stat.mockResolvedValue({ size: 75 * 1024 * 1024, isFile: () => true } as any); + mockedRNFS.stat.mockResolvedValue({ + size: 75 * 1024 * 1024, + isFile: () => true, + } as any); - await expect(whisperService.validateModelFile('/path/model.bin')).resolves.toBeUndefined(); + await expect( + whisperService.validateModelFile('/path/model.bin'), + ).resolves.toBeUndefined(); }); }); @@ -311,7 +373,9 @@ describe('WhisperService', () => { await whisperService.loadModel('/path/to/model.bin'); - expect(initWhisper).toHaveBeenCalledWith({ filePath: '/path/to/model.bin' }); + expect(initWhisper).toHaveBeenCalledWith({ + filePath: '/path/to/model.bin', + }); expect(whisperService.isModelLoaded()).toBe(true); expect(whisperService.getLoadedModelPath()).toBe('/path/to/model.bin'); }); @@ -361,7 +425,9 @@ describe('WhisperService', () => { mockValidModelFile(); mockedInitWhisper.mockRejectedValue(new Error('Load failed')); - await expect(whisperService.loadModel('/bad/model.bin')).rejects.toThrow('Load failed'); + await expect(whisperService.loadModel('/bad/model.bin')).rejects.toThrow( + 'Load failed', + ); expect(whisperService.isModelLoaded()).toBe(false); expect(whisperService.getLoadedModelPath()).toBeNull(); }); @@ -369,16 +435,23 @@ describe('WhisperService', () => { it('throws when model file is missing (prevents native crash)', async () => { mockedRNFS.exists.mockResolvedValue(false); - await expect(whisperService.loadModel('/missing/model.bin')).rejects.toThrow('not found'); + await expect( + whisperService.loadModel('/missing/model.bin'), + ).rejects.toThrow('not found'); expect(initWhisper).not.toHaveBeenCalled(); }); it('throws when model file is corrupted/too small (prevents native crash)', async () => { mockedRNFS.exists.mockResolvedValue(true); - mockedRNFS.stat.mockResolvedValue({ size: 500, isFile: () => true } as any); + mockedRNFS.stat.mockResolvedValue({ + size: 500, + isFile: () => true, + } as any); mockedRNFS.unlink.mockResolvedValue(undefined as any); - await expect(whisperService.loadModel('/corrupted/model.bin')).rejects.toThrow('too small'); + await expect( + whisperService.loadModel('/corrupted/model.bin'), + ).rejects.toThrow('too small'); expect(initWhisper).not.toHaveBeenCalled(); }); }); @@ -427,31 +500,33 @@ describe('WhisperService', () => { }); it('returns true when granted', async () => { - jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue( - PermissionsAndroid.RESULTS.GRANTED - ); + jest + .spyOn(PermissionsAndroid, 'request') + .mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED); expect(await whisperService.requestPermissions()).toBe(true); }); it('returns false when denied', async () => { - jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue( - PermissionsAndroid.RESULTS.DENIED - ); + jest + .spyOn(PermissionsAndroid, 'request') + .mockResolvedValue(PermissionsAndroid.RESULTS.DENIED); expect(await whisperService.requestPermissions()).toBe(false); }); it('returns false on permission error', async () => { - jest.spyOn(PermissionsAndroid, 'request').mockRejectedValue(new Error('Permission error')); + jest + .spyOn(PermissionsAndroid, 'request') + .mockRejectedValue(new Error('Permission error')); expect(await whisperService.requestPermissions()).toBe(false); }); it('does not touch the iOS audio session (manager mode stays null)', async () => { - jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue( - PermissionsAndroid.RESULTS.GRANTED - ); + jest + .spyOn(PermissionsAndroid, 'request') + .mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED); await whisperService.requestPermissions(); @@ -462,9 +537,9 @@ describe('WhisperService', () => { }); it('requests RECORD_AUDIO permission with correct message', async () => { - const requestSpy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue( - PermissionsAndroid.RESULTS.GRANTED - ); + const requestSpy = jest + .spyOn(PermissionsAndroid, 'request') + .mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED); await whisperService.requestPermissions(); @@ -473,7 +548,7 @@ describe('WhisperService', () => { expect.objectContaining({ title: 'Microphone Permission', buttonPositive: 'OK', - }) + }), ); }); }); @@ -492,7 +567,7 @@ describe('WhisperService', () => { // old direct AudioSessionIos path left mode stale → silent TTS after STT). expect(audioSessionManager.getMode()).toBe('record'); expect(mockSetAudioSessionOptions).toHaveBeenCalledWith( - expect.objectContaining({ iosCategory: 'playAndRecord' }) + expect.objectContaining({ iosCategory: 'playAndRecord' }), ); // Behaviour-neutral: the session is re-activated (not skipped) on the call. expect(mockSetAudioSessionActivity).toHaveBeenCalledWith(true); @@ -500,7 +575,9 @@ describe('WhisperService', () => { it('returns false when audio session activation fails (permission denied)', async () => { // A throw on activation is how iOS surfaces a denied mic permission. - mockSetAudioSessionActivity.mockRejectedValueOnce(new Error('Microphone permission denied')); + mockSetAudioSessionActivity.mockRejectedValueOnce( + new Error('Microphone permission denied'), + ); expect(await whisperService.requestPermissions()).toBe(false); // Activation failed → mode must not advance to record. @@ -556,7 +633,7 @@ describe('WhisperService', () => { it('throws when no model loaded', async () => { await expect( - whisperService.startRealtimeTranscription(jest.fn()) + whisperService.startRealtimeTranscription(jest.fn()), ).rejects.toThrow('No Whisper model loaded'); }); @@ -566,10 +643,12 @@ describe('WhisperService', () => { const mockContext = { id: 'ctx', release: jest.fn(), - transcribeRealtime: jest.fn(() => Promise.resolve({ - stop: mockStop, - subscribe: jest.fn(), - })), + transcribeRealtime: jest.fn(() => + Promise.resolve({ + stop: mockStop, + subscribe: jest.fn(), + }), + ), transcribe: jest.fn(), }; mockedInitWhisper.mockResolvedValueOnce(mockContext as any); @@ -598,21 +677,22 @@ describe('WhisperService', () => { await whisperService.loadModel('/path/model.bin'); Object.defineProperty(Platform, 'OS', { get: () => 'android' }); - jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue( - PermissionsAndroid.RESULTS.DENIED - ); + jest + .spyOn(PermissionsAndroid, 'request') + .mockResolvedValue(PermissionsAndroid.RESULTS.DENIED); await expect( - whisperService.startRealtimeTranscription(jest.fn()) + whisperService.startRealtimeTranscription(jest.fn()), ).rejects.toThrow('Microphone permission denied'); }); - it('calls transcribeRealtime with correct options', async () => { + it('stops the exact session when stop arrives while permission is pending', async () => { + const nativeStop = jest.fn(async () => undefined); const mockContext = { id: 'ctx', release: jest.fn(), - transcribeRealtime: jest.fn(() => Promise.resolve({ - stop: jest.fn(), + transcribeRealtime: jest.fn(async () => ({ + stop: nativeStop, subscribe: jest.fn(), })), transcribe: jest.fn(), @@ -620,15 +700,62 @@ describe('WhisperService', () => { mockedInitWhisper.mockResolvedValueOnce(mockContext as any); await whisperService.loadModel('/path/model.bin'); + Object.defineProperty(Platform, 'OS', { get: () => 'android' }); + let answerPermission!: (result: string) => void; + let permissionRequests = 0; + jest.spyOn(PermissionsAndroid, 'request').mockImplementation(() => { + permissionRequests += 1; + if (permissionRequests > 1) { + return Promise.resolve(PermissionsAndroid.RESULTS.GRANTED); + } + return new Promise(resolve => { + answerPermission = resolve; + }) as Promise; + }); + + const start = whisperService.startRealtimeTranscription(jest.fn()); + await Promise.resolve(); + const stop = whisperService.stopTranscription(); + await Promise.resolve(); + expect(mockContext.transcribeRealtime).not.toHaveBeenCalled(); + + answerPermission(PermissionsAndroid.RESULTS.GRANTED); + await Promise.all([start, stop]); + + expect(mockContext.transcribeRealtime).toHaveBeenCalledTimes(1); + expect(nativeStop).toHaveBeenCalledTimes(1); + expect(whisperService.isCurrentlyTranscribing()).toBe(false); + }); + + it('calls transcribeRealtime with correct options', async () => { + const mockContext = { + id: 'ctx', + release: jest.fn(), + transcribeRealtime: jest.fn(() => + Promise.resolve({ + stop: jest.fn(), + subscribe: jest.fn(), + }), + ), + transcribe: jest.fn(), + }; + mockedInitWhisper.mockResolvedValueOnce(mockContext as any); + await whisperService.loadModel('/path/model.bin'); + Object.defineProperty(Platform, 'OS', { get: () => 'ios' }); - await whisperService.startRealtimeTranscription(jest.fn(), { language: 'fr', maxLen: 100 }); + await whisperService.startRealtimeTranscription(jest.fn(), { + language: 'fr', + maxLen: 100, + }); expect(mockContext.transcribeRealtime).toHaveBeenCalledWith( expect.objectContaining({ language: 'fr', + translate: false, + beamSize: 5, maxLen: 100, - }) + }), ); }); @@ -636,10 +763,12 @@ describe('WhisperService', () => { const mockContext = { id: 'ctx', release: jest.fn(), - transcribeRealtime: jest.fn(() => Promise.resolve({ - stop: jest.fn(), - subscribe: jest.fn(), - })), + transcribeRealtime: jest.fn(() => + Promise.resolve({ + stop: jest.fn(), + subscribe: jest.fn(), + }), + ), transcribe: jest.fn(), }; mockedInitWhisper.mockResolvedValueOnce(mockContext as any); @@ -657,7 +786,7 @@ describe('WhisperService', () => { mode: 'Default', }), audioSessionOnStopIos: 'restore', - }) + }), ); }); @@ -665,19 +794,21 @@ describe('WhisperService', () => { const mockContext = { id: 'ctx', release: jest.fn(), - transcribeRealtime: jest.fn((..._args: any[]) => Promise.resolve({ - stop: jest.fn(), - subscribe: jest.fn(), - })), + transcribeRealtime: jest.fn((..._args: any[]) => + Promise.resolve({ + stop: jest.fn(), + subscribe: jest.fn(), + }), + ), transcribe: jest.fn(), }; mockedInitWhisper.mockResolvedValueOnce(mockContext as any); await whisperService.loadModel('/path/model.bin'); Object.defineProperty(Platform, 'OS', { get: () => 'android' }); - jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue( - PermissionsAndroid.RESULTS.GRANTED - ); + jest + .spyOn(PermissionsAndroid, 'request') + .mockResolvedValue(PermissionsAndroid.RESULTS.GRANTED); await whisperService.startRealtimeTranscription(jest.fn()); @@ -691,10 +822,14 @@ describe('WhisperService', () => { const mockContext = { id: 'ctx', release: jest.fn(), - transcribeRealtime: jest.fn(() => Promise.resolve({ - stop: jest.fn(), - subscribe: (fn: any) => { subscribeFn = fn; }, - })), + transcribeRealtime: jest.fn(() => + Promise.resolve({ + stop: jest.fn(), + subscribe: (fn: any) => { + subscribeFn = fn; + }, + }), + ), transcribe: jest.fn(), }; mockedInitWhisper.mockResolvedValueOnce(mockContext as any); @@ -720,6 +855,63 @@ describe('WhisperService', () => { recordingTime: 200, }); }); + + it('keeps the selected language when the realtime result falls back to the recorded file', async () => { + let subscribeFn: any; + const mockContext = { + id: 'ctx', + release: jest.fn(), + transcribeRealtime: jest.fn(() => + Promise.resolve({ + stop: jest.fn(), + subscribe: (fn: any) => { + subscribeFn = fn; + }, + }), + ), + transcribe: jest.fn(() => ({ + stop: jest.fn(), + promise: Promise.resolve({ result: 'नमस्ते दुनिया' }), + })), + }; + mockedInitWhisper.mockResolvedValueOnce(mockContext as any); + await whisperService.loadModel('/path/model.bin'); + Object.defineProperty(Platform, 'OS', { get: () => 'ios' }); + jest.spyOn(audioRecorderService, 'startRecording').mockResolvedValue(); + jest.spyOn(audioRecorderService, 'stopRecording').mockResolvedValue({ + path: '/recorded-hindi.wav', + durationSeconds: 1, + }); + + const resultCb = jest.fn(); + await whisperService.startRealtimeTranscription(resultCb, { + language: 'hi', + }); + subscribeFn({ isCapturing: false, data: { result: '' } }); + await (whisperService as any).transcriptionFullyStopped; + + expect(mockContext.transcribe).toHaveBeenCalledWith( + '/recorded-hindi.wav', + expect.objectContaining({ + language: 'hi', + translate: false, + temperature: 0, + beamSize: 5, + }), + ); + const decodeOptions = ( + mockContext.transcribe.mock.calls as unknown as Array< + [string, Record] + > + )[0][1]; + expect(decodeOptions).not.toHaveProperty('prompt'); + expect(resultCb).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'नमस्ते दुनिया', + isCapturing: false, + }), + ); + }); }); // ======================================================================== @@ -752,7 +944,9 @@ describe('WhisperService', () => { }); it('handles error in stop function gracefully', async () => { - (whisperService as any).stopFn = () => { throw new Error('stop error'); }; + (whisperService as any).stopFn = () => { + throw new Error('stop error'); + }; (whisperService as any).isTranscribing = true; (whisperService as any).context = { release: jest.fn() }; @@ -773,7 +967,7 @@ describe('WhisperService', () => { describe('transcribeFile', () => { it('throws when no model loaded', async () => { await expect( - whisperService.transcribeFile('/path/to/audio.wav') + whisperService.transcribeFile('/path/to/audio.wav'), ).rejects.toThrow('No Whisper model loaded'); }); @@ -793,9 +987,12 @@ describe('WhisperService', () => { const result = await whisperService.transcribeFile('/audio.wav'); expect(result).toBe('transcribed text'); - expect(mockContext.transcribe).toHaveBeenCalledWith('/audio.wav', expect.objectContaining({ - language: 'en', - })); + expect(mockContext.transcribe).toHaveBeenCalledWith( + '/audio.wav', + expect.objectContaining({ + language: 'en', + }), + ); }); }); @@ -803,49 +1000,72 @@ describe('WhisperService', () => { // forceReset // ======================================================================== describe('forceReset', () => { - it('resets transcription state', () => { + it('resets transcription state', async () => { (whisperService as any).isTranscribing = true; (whisperService as any).stopFn = jest.fn(); - whisperService.forceReset(); + await whisperService.forceReset(); expect(whisperService.isCurrentlyTranscribing()).toBe(false); }); - it('calls native stopFn when context exists (prevents SIGSEGV)', () => { + it('calls native stopFn when context exists (prevents SIGSEGV)', async () => { const mockStopFn = jest.fn(); (whisperService as any).isTranscribing = true; (whisperService as any).stopFn = mockStopFn; (whisperService as any).context = { release: jest.fn() }; - whisperService.forceReset(); + await whisperService.forceReset(); expect(mockStopFn).toHaveBeenCalled(); expect(whisperService.isCurrentlyTranscribing()).toBe(false); }); - it('does not call stopFn when context is null (prevents SIGSEGV on freed context)', () => { + it('does not call stopFn when context is null (prevents SIGSEGV on freed context)', async () => { const mockStopFn = jest.fn(); (whisperService as any).isTranscribing = true; (whisperService as any).stopFn = mockStopFn; (whisperService as any).context = null; - whisperService.forceReset(); + await whisperService.forceReset(); expect(mockStopFn).not.toHaveBeenCalled(); expect(whisperService.isCurrentlyTranscribing()).toBe(false); }); - it('handles stopFn error gracefully during forceReset', () => { + it('handles stopFn error gracefully during forceReset', async () => { (whisperService as any).isTranscribing = true; - (whisperService as any).stopFn = () => { throw new Error('stop error'); }; + (whisperService as any).stopFn = () => { + throw new Error('stop error'); + }; (whisperService as any).context = { release: jest.fn() }; // Should not throw - whisperService.forceReset(); + await whisperService.forceReset(); expect(whisperService.isCurrentlyTranscribing()).toBe(false); }); + + it('does not finish until the native realtime job has stopped', async () => { + let finishNativeStop: (() => void) | undefined; + const nativeStop = new Promise(resolve => { + finishNativeStop = resolve; + }); + (whisperService as any).isTranscribing = true; + (whisperService as any).stopFn = jest.fn(() => nativeStop); + (whisperService as any).context = { release: jest.fn() }; + + let resetFinished = false; + const reset = whisperService.forceReset().then(() => { + resetFinished = true; + }); + await Promise.resolve(); + + expect(resetFinished).toBe(false); + finishNativeStop?.(); + await reset; + expect(resetFinished).toBe(true); + }); }); // ======================================================================== diff --git a/__tests__/unit/stores/downloadStore.test.ts b/__tests__/unit/stores/downloadStore.test.ts index 5f1da6c01..e432dd6a2 100644 --- a/__tests__/unit/stores/downloadStore.test.ts +++ b/__tests__/unit/stores/downloadStore.test.ts @@ -172,6 +172,21 @@ describe('updateProgress', () => { useDownloadStore.getState().updateProgress('dl-1', 1000, 1000); // (1000+400)/1000 = 1.4 → clamp expect(useDownloadStore.getState().downloads['author/model/model.gguf'].progress).toBe(1); }); + + it('measures one live rate in the canonical store for every view', () => { + const now = jest.spyOn(Date, 'now'); + now.mockReturnValueOnce(1_000).mockReturnValueOnce(2_000); + useDownloadStore.getState().add(makeEntry()); + + useDownloadStore.getState().updateProgress('dl-1', 100, 1000); + expect(useDownloadStore.getState().downloads['author/model/model.gguf'].bytesPerSecond).toBeUndefined(); + useDownloadStore.getState().updateProgress('dl-1', 600, 1000); + + const entry = useDownloadStore.getState().downloads['author/model/model.gguf']; + expect(entry.bytesPerSecond).toBe(500); + expect(entry.rateSample).toEqual({ currentBytes: 600, sampledAtMs: 2_000 }); + now.mockRestore(); + }); }); describe('updateMmProjProgress', () => { @@ -220,6 +235,25 @@ describe('setStatus', () => { useDownloadStore.getState().setStatus('unknown', 'failed'); expect(useDownloadStore.getState().downloads).toBe(before); }); + + it('keeps the aggregate rate while the sidecar still transfers', () => { + useDownloadStore.getState().add(makeEntry({ + status: 'running', + mmProjDownloadId: 'dl-mm', + mmProjStatus: 'running', + bytesPerSecond: 512, + rateSample: { currentBytes: 500, sampledAtMs: 1_000 }, + })); + + useDownloadStore.getState().setCompleted('dl-1'); + let entry = useDownloadStore.getState().downloads['author/model/model.gguf']; + expect(entry.bytesPerSecond).toBe(512); + + useDownloadStore.getState().setMmProjCompleted('dl-mm', 500); + entry = useDownloadStore.getState().downloads['author/model/model.gguf']; + expect(entry.bytesPerSecond).toBeUndefined(); + expect(entry.rateSample).toBeUndefined(); + }); }); describe('setProcessing / setCompleted', () => { diff --git a/__tests__/unit/stores/remoteServerStore.test.ts b/__tests__/unit/stores/remoteServerStore.test.ts index f494c31de..f87fbbe02 100644 --- a/__tests__/unit/stores/remoteServerStore.test.ts +++ b/__tests__/unit/stores/remoteServerStore.test.ts @@ -720,8 +720,8 @@ describe('remoteServerStore', () => { }); }); - describe('fetchModelsFromServer with apiKey', () => { - it('should use Authorization header when apiKey is provided', async () => { + describe('store credential boundary', () => { + it('does not persist or send an API key passed directly to the public store', async () => { const mockFetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ @@ -744,8 +744,9 @@ describe('remoteServerStore', () => { await useRemoteServerStore.getState().discoverModels(serverId); expect(mockFetch).toHaveBeenCalled(); + expect(useRemoteServerStore.getState().getServerById(serverId)).not.toHaveProperty('apiKey'); const callArgs = mockFetch.mock.calls[0]; - expect(callArgs[1].headers.Authorization).toBe('Bearer secret-key'); + expect(callArgs[1].headers).not.toHaveProperty('Authorization'); }); }); @@ -936,4 +937,4 @@ describe('remoteServerStore', () => { expect(ids).not.toContain('nomic-embed-text'); }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/unit/stores/ttsStore.test.ts b/__tests__/unit/stores/ttsStore.test.ts index 7a625590a..ca4481560 100644 --- a/__tests__/unit/stores/ttsStore.test.ts +++ b/__tests__/unit/stores/ttsStore.test.ts @@ -88,6 +88,11 @@ const resetState = () => { overallDownloadProgress: 1, voices: [{ id: 'default', label: 'Default', metadata: {} }], activeVoiceId: 'default', + isSwitchingVoice: false, + pendingVoiceId: null, + failedVoiceId: null, + voiceSwitchProgress: 0, + voiceSwitchNeedsDownload: false, audioCacheSizeMB: 0, settings: { interfaceMode: 'chat', @@ -95,6 +100,8 @@ const resetState = () => { speed: 1.0, engineId: 'mock-tts', voiceByEngine: {}, + modelDownloaded: {}, + voiceAssetsDownloaded: {}, }, }); }; @@ -164,13 +171,36 @@ describe('ttsStore', () => { }); describe('setVoice (logged, timeout-guarded switch)', () => { - it('clears isSwitchingVoice after a successful switch', async () => { - mockEngine.setVoice.mockResolvedValueOnce(undefined); - await getState().setVoice('default'); - expect(mockEngine.setVoice).toHaveBeenCalledWith('default'); + it('keeps the current voice active until the requested voice is ready', async () => { + let finishSwitch!: () => void; + mockEngine.setVoice.mockReturnValueOnce(new Promise((resolve) => { finishSwitch = resolve; })); + const switching = getState().setVoice('next'); + + expect(getState().activeVoiceId).toBe('default'); + expect(getState().pendingVoiceId).toBe('next'); + expect(getState().isSwitchingVoice).toBe(true); + + finishSwitch(); + await switching; + expect(mockEngine.setVoice).toHaveBeenCalledWith('next'); + expect(getState().activeVoiceId).toBe('next'); + expect(getState().settings.voiceByEngine['mock-tts']).toBe('next'); + expect(getState().settings.voiceAssetsDownloaded?.['mock-tts']).toContain('next'); + expect(getState().pendingVoiceId).toBeNull(); expect(getState().isSwitchingVoice).toBe(false); }); + it('distinguishes a first download from preparing a completed voice', async () => { + const firstSwitch = getState().setVoice('next'); + expect(getState().voiceSwitchNeedsDownload).toBe(true); + await firstSwitch; + + useTTSStore.setState({ activeVoiceId: 'default' }); + const cachedSwitch = getState().setVoice('next'); + expect(getState().voiceSwitchNeedsDownload).toBe(false); + await cachedSwitch; + }); + it('does NOT hang when the engine voice fetch never settles — times out and recovers', async () => { _setVoiceSwitchTimeoutForTest(20); mockEngine.setVoice.mockReturnValueOnce(new Promise(() => { /* never resolves (stuck native fetch) */ })); @@ -178,6 +208,8 @@ describe('ttsStore', () => { // The spinner must clear and an error surfaces — never a permanent stuck state. expect(getState().isSwitchingVoice).toBe(false); expect(getState().error).toMatch(/timed out/i); + expect(getState().failedVoiceId).toBe('default'); + expect(getState().pendingVoiceId).toBeNull(); _setVoiceSwitchTimeoutForTest(45000); }); @@ -186,6 +218,7 @@ describe('ttsStore', () => { await getState().setVoice('default'); expect(getState().isSwitchingVoice).toBe(false); expect(getState().error).toBe('fetch failed'); + expect(getState().failedVoiceId).toBe('default'); }); it('deleteModels clears a stuck isSwitchingVoice (delete mid-switch must not lock the picker)', async () => { @@ -226,9 +259,10 @@ describe('ttsStore', () => { describe('clearError', () => { it('clears the error field', () => { - useTTSStore.setState({ error: 'something went wrong' }); + useTTSStore.setState({ error: 'something went wrong', failedVoiceId: 'next' }); getState().clearError(); expect(getState().error).toBeNull(); + expect(getState().failedVoiceId).toBeNull(); }); }); diff --git a/__tests__/unit/sync/entitlementHostBootstrap.test.ts b/__tests__/unit/sync/entitlementHostBootstrap.test.ts new file mode 100644 index 000000000..0e43fb4e3 --- /dev/null +++ b/__tests__/unit/sync/entitlementHostBootstrap.test.ts @@ -0,0 +1,106 @@ +import type { DeviceInfo } from '@offgrid/sync'; +import { EntitlementHostBootstrap } from '../../../pro/sync/entitlementHostBootstrap'; + +const localDevice: DeviceInfo = { + id: 'phone-1', + name: 'Phone', + platform: 'ios', + version: '1', + host: '192.168.1.20', + port: 37878, +}; + +function options(overrides: { + createLocalDevice?: () => Promise; + registerOwner?: jest.Mock; +} = {}) { + let published: DeviceInfo | null = null; + const registerOwner = overrides.registerOwner ?? jest.fn(() => jest.fn()); + return { + registerOwner, + published: () => published, + value: { + localDevice: () => published, + createLocalDevice: + overrides.createLocalDevice ?? + jest.fn(async () => ({ ...localDevice })), + publishLocalDevice: (device: DeviceInfo) => { + published = device; + }, + membershipOwner: () => null, + registerOwner, + onReconciliationChanged: () => undefined, + onLocalAdmissionChanged: () => undefined, + onRegistryChanged: async () => undefined, + }, + }; +} + +describe('EntitlementHostBootstrap', () => { + it('registers the activation owner before full Sync starts', async () => { + const boundary = options(); + const bootstrap = new EntitlementHostBootstrap(boundary.value); + + await bootstrap.prepare(); + + expect(boundary.registerOwner).toHaveBeenCalledTimes(1); + expect(boundary.published()).toEqual(localDevice); + expect(bootstrap.current()).toBeTruthy(); + }); + + it('shares one owner across concurrent activation and Sync preparation', async () => { + let finish!: (device: DeviceInfo) => void; + const createLocalDevice = jest.fn( + () => new Promise(resolve => (finish = resolve)), + ); + const boundary = options({ createLocalDevice }); + const bootstrap = new EntitlementHostBootstrap(boundary.value); + + const activationPrepare = bootstrap.prepare(); + const syncStartPrepare = bootstrap.prepare(); + finish({ ...localDevice }); + + const [activation, sync] = await Promise.all([ + activationPrepare, + syncStartPrepare, + ]); + expect(activation.host).toBe(sync.host); + expect(createLocalDevice).toHaveBeenCalledTimes(1); + expect(boundary.registerOwner).toHaveBeenCalledTimes(1); + }); + + it('does not register an owner after release cancels in-flight preparation', async () => { + let finish!: (device: DeviceInfo) => void; + const boundary = options({ + createLocalDevice: () => + new Promise(resolve => { + finish = resolve; + }), + }); + const bootstrap = new EntitlementHostBootstrap(boundary.value); + + const preparation = bootstrap.prepare(); + bootstrap.release(); + finish({ ...localDevice }); + + await expect(preparation).rejects.toThrow('cancelled'); + expect(boundary.registerOwner).not.toHaveBeenCalled(); + expect(bootstrap.current()).toBeNull(); + }); + + it('can retry after local-device preparation fails', async () => { + const createLocalDevice = jest + .fn, []>() + .mockRejectedValueOnce(new Error('Keychain unavailable')) + .mockResolvedValueOnce({ ...localDevice }); + const boundary = options({ createLocalDevice }); + const bootstrap = new EntitlementHostBootstrap(boundary.value); + + await expect(bootstrap.prepare()).rejects.toThrow('Keychain unavailable'); + await expect(bootstrap.prepare()).resolves.toMatchObject({ + localDevice, + }); + expect(createLocalDevice).toHaveBeenCalledTimes(2); + expect(boundary.registerOwner).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/unit/sync/licenceRevalidationBudget.test.ts b/__tests__/unit/sync/licenceRevalidationBudget.test.ts new file mode 100644 index 000000000..25bb7981d --- /dev/null +++ b/__tests__/unit/sync/licenceRevalidationBudget.test.ts @@ -0,0 +1,26 @@ +import { settleWithinBudget } from '../../../pro/sync/licenceRevalidationBudget'; + +describe('licence revalidation startup budget', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('releases the budget timer when revalidation finishes first', async () => { + await settleWithinBudget(Promise.resolve(), 3_000); + + expect(jest.getTimerCount()).toBe(0); + }); + + it('continues when the budget finishes before revalidation', async () => { + const pendingOperation = new Promise(() => undefined); + const result = settleWithinBudget(pendingOperation, 3_000); + + await jest.advanceTimersByTimeAsync(3_000); + await expect(result).resolves.toBeUndefined(); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/__tests__/unit/sync/meshResidencyPolicy.test.ts b/__tests__/unit/sync/meshResidencyPolicy.test.ts index 18fa68383..68574b928 100644 --- a/__tests__/unit/sync/meshResidencyPolicy.test.ts +++ b/__tests__/unit/sync/meshResidencyPolicy.test.ts @@ -20,25 +20,32 @@ import { NativeModules } from 'react-native'; const residency = { - begin: jest.fn, []>(), + begin: jest.fn, []>(), end: jest.fn, []>(), + state: jest.fn, []>(), getConstants: jest.fn(() => ({ survivesBackground: true, backgroundGraceSeconds: null, - showsOngoingIndicator: true - })) + showsOngoingIndicator: true, + })), }; beforeEach(() => { jest.resetModules(); - residency.begin.mockReset().mockResolvedValue(undefined); + residency.begin.mockReset().mockResolvedValue({ status: 'background' }); residency.end.mockReset().mockResolvedValue(undefined); - (NativeModules as unknown as Record).MeshResidencyModule = residency; + residency.state.mockReset().mockResolvedValue({ status: 'background' }); + (NativeModules as unknown as Record).MeshResidencyModule = + residency; }); const policy = (): typeof import('../../../pro/sync/meshResidency') => require('../../../pro/sync/meshResidency'); +afterEach(async () => { + await policy().releaseMeshResidency(); +}); + describe('holding the mesh awake in the background', () => { it('asks the platform to hold it', async () => { await policy().holdMeshResidency(); @@ -48,18 +55,27 @@ describe('holding the mesh awake in the background', () => { it('does not fail sync start when the platform refuses to hold it', async () => { // Exactly what Android returns from a restricted state - the foreground service is simply not allowed. - residency.begin.mockRejectedValue(new Error('ForegroundServiceStartNotAllowedException')); + residency.begin.mockRejectedValue( + new Error('ForegroundServiceStartNotAllowedException'), + ); // Resolves. If this rejected, syncService.start would unwind and the user would have NO mesh, foreground // included, because the OS declined an optimisation. - await expect(policy().holdMeshResidency()).resolves.toBeUndefined(); + await expect(policy().holdMeshResidency()).resolves.toEqual({ + status: 'foreground_only', + reason: 'promotion_denied', + }); }); it('does not fail sync start when the native module is missing entirely', async () => { - delete (NativeModules as unknown as Record).MeshResidencyModule; + delete (NativeModules as unknown as Record) + .MeshResidencyModule; // An older build, or a platform where nothing implements it. Sync still has to come up. - await expect(policy().holdMeshResidency()).resolves.toBeUndefined(); + await expect(policy().holdMeshResidency()).resolves.toEqual({ + status: 'foreground_only', + reason: 'unavailable', + }); }); }); diff --git a/__tests__/unit/sync/nativeBlobChannel.test.ts b/__tests__/unit/sync/nativeBlobChannel.test.ts index 82d603d25..5702b1872 100644 --- a/__tests__/unit/sync/nativeBlobChannel.test.ts +++ b/__tests__/unit/sync/nativeBlobChannel.test.ts @@ -9,6 +9,7 @@ import { NativeEventBus } from '../../utils/nativeEventBus'; import { createNativeBlobChannel, hasNativeBlobChannel, + nativePairingRouteCandidates, } from '../../../src/services/sync/nativeBlobChannel'; jest.mock('react-native', () => { @@ -54,6 +55,11 @@ class BlobNativeFake extends NativeEventBus { /** null is a platform that cannot host an endpoint right now - no port, no permission. */ endpoint: { url: string } | null = { url: 'http://192.168.1.50:9999/blob/1' }; streamFailure: Error | undefined; + candidates: unknown = []; + + async interfaceCandidates(): Promise { + return this.candidates; + } async serve(options: ServeOptions): Promise<{ url: string } | null> { this.served.push(options); @@ -166,6 +172,50 @@ describe('moving a large file natively between two devices', () => { }); }); + describe('listing this device routes for pairing', () => { + it('preserves interface identity for the shared route projector', async () => { + platform.candidates = [ + { host: '192.168.1.20', interfaceName: 'en0' }, + { host: '100.84.2.9', interfaceName: 'utun4' }, + ]; + + await expect(nativePairingRouteCandidates()).resolves.toEqual([ + { host: '192.168.1.20', interfaceName: 'en0' }, + { host: '100.84.2.9', interfaceName: 'utun4' }, + ]); + }); + + it('fails closed when native data is absent or malformed', async () => { + platform.candidates = [ + null, + '192.168.1.20', + { host: '' }, + { host: '10.0.0.3', interfaceName: 4 }, + { host: '10.0.0.4' }, + ]; + await expect(nativePairingRouteCandidates()).resolves.toEqual([ + { host: '10.0.0.4' }, + ]); + + delete native.SyncBlobChannelModule; + await expect(nativePairingRouteCandidates()).resolves.toEqual([]); + }); + + it('reads the live interfaces each time the existing address owner asks', async () => { + let read = 0; + platform.interfaceCandidates = async () => [ + { host: `192.168.1.${++read}`, interfaceName: 'wlan0' }, + ]; + + await expect(nativePairingRouteCandidates()).resolves.toEqual([ + { host: '192.168.1.1', interfaceName: 'wlan0' }, + ]); + await expect(nativePairingRouteCandidates()).resolves.toEqual([ + { host: '192.168.1.2', interfaceName: 'wlan0' }, + ]); + }); + }); + describe('offering somewhere for a file to land', () => { it('hands the platform the key material and offers the peer the endpoint', async () => { const offered = await channelFor().serve?.(request()); diff --git a/__tests__/unit/sync/nativeMeshResidency.test.ts b/__tests__/unit/sync/nativeMeshResidency.test.ts index bd1f5b51e..a3d4857b2 100644 --- a/__tests__/unit/sync/nativeMeshResidency.test.ts +++ b/__tests__/unit/sync/nativeMeshResidency.test.ts @@ -2,8 +2,9 @@ import { NativeModules } from 'react-native'; import { nativeMeshResidencyBoundary } from '../../../src/services/sync/nativeMeshResidency'; interface ResidencyFake { - begin: jest.Mock, []>; + begin: jest.Mock, []>; end: jest.Mock, []>; + state: jest.Mock, []>; getConstants: jest.Mock; } @@ -24,8 +25,9 @@ describe('what the phone promises about staying reachable in the background', () const install = (constants: unknown): ResidencyFake => { const fake: ResidencyFake = { - begin: jest.fn(async () => undefined), + begin: jest.fn(async () => ({ status: 'background' })), end: jest.fn(async () => undefined), + state: jest.fn(async () => ({ status: 'background' })), getConstants: jest.fn(() => constants), }; native.MeshResidencyModule = fake; @@ -164,17 +166,26 @@ describe('what the phone promises about staying reachable in the background', () showsOngoingIndicator: true, }); - await nativeMeshResidencyBoundary.begin(); + await expect(nativeMeshResidencyBoundary.begin()).resolves.toEqual({ + status: 'background', + }); + await expect(nativeMeshResidencyBoundary.state()).resolves.toEqual({ + status: 'background', + }); await nativeMeshResidencyBoundary.end(); expect(fake.begin).toHaveBeenCalledTimes(1); + expect(fake.state).toHaveBeenCalledTimes(1); expect(fake.end).toHaveBeenCalledTimes(1); }); it('is safe to hold and release on a build that cannot do either', async () => { // No module. Releasing residency that was never held happens on every app close, and a throw there // would surface as a crash on backgrounding. - await expect(nativeMeshResidencyBoundary.begin()).resolves.toBeUndefined(); + await expect(nativeMeshResidencyBoundary.begin()).resolves.toEqual({ + status: 'foreground_only', + reason: 'unavailable', + }); await expect(nativeMeshResidencyBoundary.end()).resolves.toBeUndefined(); }); diff --git a/__tests__/unit/sync/nativeProximity.test.ts b/__tests__/unit/sync/nativeProximity.test.ts index 515dbd892..c02e465d1 100644 --- a/__tests__/unit/sync/nativeProximity.test.ts +++ b/__tests__/unit/sync/nativeProximity.test.ts @@ -4,6 +4,7 @@ import type { DiscoveredDevice, SyncConnection, } from '@offgrid/sync'; +import { DiscoveryOrchestrator } from '@offgrid/sync'; import { CONNECTION_CLOSED_EVENT, CONNECTION_OPENED_EVENT, @@ -16,6 +17,8 @@ import { type ProximityNativeFake, } from '../../utils/proximityNativeBoundary'; import { IosProximityAdapter } from '../../../src/services/sync/nativeProximity'; +import { buildSyncEngine } from '../../../src/services/sync/engine'; +import { createNativeTcpBoundary } from '../../utils/nativeSyncBoundaries'; jest.mock('react-native', () => { const boundary = require('../../utils/proximityNativeBoundary'); @@ -74,6 +77,30 @@ describe('two phones talking with no network between them', () => { const bytes = (text: string) => new Uint8Array(Buffer.from(text, 'utf8')); const text = (data: Uint8Array) => Buffer.from(data).toString('utf8'); + const startVisible = async ( + adapter: IosProximityAdapter, + device: DeviceInfo, + ) => { + await adapter.discovery.start(); + await adapter.discovery.advertise(device); + }; + const orchestrate = ( + adapter: IosProximityAdapter, + device: DeviceInfo, + discoverable: boolean, + ) => { + const { engine } = buildSyncEngine({ + localDevice: device, + tcpModule: createNativeTcpBoundary(), + }); + return new DiscoveryOrchestrator({ + engine, + discovery: adapter.discovery, + localDevice: device, + discoverable, + getSharedSecret: () => undefined, + }); + }; beforeEach(() => { platform.OS = 'ios'; @@ -92,8 +119,8 @@ describe('two phones talking with no network between them', () => { const found: DiscoveredDevice[] = []; phoneA.discovery.onDeviceFound(device => found.push(device)); - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await startVisible(phoneA, PHONE_A); + await startVisible(phoneB, PHONE_B); expect(found).toHaveLength(1); expect(found[0]).toMatchObject({ @@ -108,8 +135,8 @@ describe('two phones talking with no network between them', () => { }); it('replays the phones it already found to a listener that arrives late', async () => { - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await startVisible(phoneA, PHONE_A); + await startVisible(phoneB, PHONE_B); const found: DiscoveredDevice[] = []; phoneA.discovery.onDeviceFound(device => found.push(device)); @@ -122,7 +149,7 @@ describe('two phones talking with no network between them', () => { it('never offers the phone itself as a peer', async () => { const found: DiscoveredDevice[] = []; phoneA.discovery.onDeviceFound(device => found.push(device)); - await phoneA.discovery.start(); + await startVisible(phoneA, PHONE_A); nativeA.emit(PEER_FOUND_EVENT, { device: PHONE_A }); @@ -134,8 +161,8 @@ describe('two phones talking with no network between them', () => { it('reports a phone that walked out of range', async () => { const lost: string[] = []; - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await startVisible(phoneA, PHONE_A); + await startVisible(phoneB, PHONE_B); phoneA.discovery.onDeviceLost(deviceId => lost.push(deviceId)); expect(phoneA.canConnect(PHONE_B)).toBe(true); @@ -147,8 +174,8 @@ describe('two phones talking with no network between them', () => { }); it('counts the phones in range in the health it reports', async () => { - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await startVisible(phoneA, PHONE_A); + await startVisible(phoneB, PHONE_B); expect(discoveryRoute()).toMatchObject({ id: 'proximity', peerCount: 1 }); @@ -177,7 +204,7 @@ describe('two phones talking with no network between them', () => { ])('ignores a peer announcement with %s', async (_label, payload) => { const found: DiscoveredDevice[] = []; phoneA.discovery.onDeviceFound(device => found.push(device)); - await phoneA.discovery.start(); + await startVisible(phoneA, PHONE_A); nativeA.emit(PEER_FOUND_EVENT, payload); @@ -189,7 +216,7 @@ describe('two phones talking with no network between them', () => { it('accepts a peer that did not say which version it speaks', async () => { const found: DiscoveredDevice[] = []; phoneA.discovery.onDeviceFound(device => found.push(device)); - await phoneA.discovery.start(); + await startVisible(phoneA, PHONE_A); nativeA.emit(PEER_FOUND_EVENT, { device: { id: 'phone-b', name: 'The iPad', platform: 'ios' }, @@ -204,8 +231,8 @@ describe('two phones talking with no network between them', () => { ['no device id', {}], ['a device id that is not text', { deviceId: 7 }], ])('ignores a peer-lost event with %s', async (_label, payload) => { - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await startVisible(phoneA, PHONE_A); + await startVisible(phoneB, PHONE_B); const lost: string[] = []; phoneA.discovery.onDeviceLost(deviceId => lost.push(deviceId)); @@ -219,8 +246,8 @@ describe('two phones talking with no network between them', () => { it('finds the other phone again after it renames itself', async () => { const found: DiscoveredDevice[] = []; - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await startVisible(phoneA, PHONE_A); + await startVisible(phoneB, PHONE_B); phoneA.discovery.onDeviceFound(device => found.push(device)); found.length = 0; @@ -236,7 +263,8 @@ describe('two phones talking with no network between them', () => { const connected = async () => { const inbound: SyncConnection[] = []; await phoneB.listen(0, connection => inbound.push(connection)); - await phoneA.discovery.start(); + await phoneB.discovery.advertise(PHONE_B); + await startVisible(phoneA, PHONE_A); const outbound = await phoneA.connect('', 0, PHONE_B); return { outbound, inbound }; }; @@ -292,7 +320,7 @@ describe('two phones talking with no network between them', () => { }); it('keeps frames that arrive before the connection object even exists', async () => { - await phoneA.discovery.start(); + await startVisible(phoneA, PHONE_A); const inbound: SyncConnection[] = []; // Native reports data on a connection this side has not been told about yet - the open event and the @@ -362,8 +390,8 @@ describe('two phones talking with no network between them', () => { it('reuses the session when both phones invite each other at once', async () => { const inbound: SyncConnection[] = []; await phoneA.listen(0, connection => inbound.push(connection)); - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await phoneA.discovery.advertise(PHONE_A); + await startVisible(phoneB, PHONE_B); nativeA.emit(CONNECTION_OPENED_EVENT, { connectionId: 'proximity-1', deviceId: 'phone-b', @@ -402,7 +430,8 @@ describe('two phones talking with no network between them', () => { const connected = async () => { const inbound: SyncConnection[] = []; await phoneB.listen(0, connection => inbound.push(connection)); - await phoneA.discovery.start(); + await phoneB.discovery.advertise(PHONE_B); + await startVisible(phoneA, PHONE_A); const outbound = await phoneA.connect('', 0, PHONE_B); return { outbound, inbound }; }; @@ -514,8 +543,8 @@ describe('two phones talking with no network between them', () => { }); it('refuses to reach a phone that is no longer nearby', async () => { - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await startVisible(phoneA, PHONE_A); + await startVisible(phoneB, PHONE_B); air.lose(nativeA, 'phone-b'); await expect(phoneA.connect('', 0, PHONE_B)).rejects.toThrow( @@ -524,7 +553,7 @@ describe('two phones talking with no network between them', () => { }); it('refuses when it is not told which phone to reach', async () => { - await phoneA.discovery.start(); + await startVisible(phoneA, PHONE_A); // A LAN host and port mean nothing here, so a call without the device is a caller that thinks this is // TCP. Failing loudly beats dialling nothing. @@ -534,8 +563,8 @@ describe('two phones talking with no network between them', () => { }); it('reports the native failure when the other phone refuses the connection', async () => { - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await startVisible(phoneA, PHONE_A); + await startVisible(phoneB, PHONE_B); nativeA.connectFailure = new Error('Peer declined the invitation.'); await expect(phoneA.connect('', 0, PHONE_B)).rejects.toThrow( @@ -557,7 +586,7 @@ describe('two phones talking with no network between them', () => { }); }); - it('is ready once the phone is advertising', async () => { + it('starts browsing without advertising', async () => { const starting = phoneA.discovery.start(); // Caught mid-flight: a screen that opened during this shows "starting", not a blank state. expect(phoneA.getTransportHealthSnapshot().listener.state).toBe( @@ -566,12 +595,83 @@ describe('two phones talking with no network between them', () => { await starting; + expect(nativeA.calls).toEqual(['start']); + expect(nativeA.advertising).toBe(false); + expect(discoveryRoute()).toMatchObject({ + browse: { state: 'ready' }, + advertise: { state: 'stopped' }, + }); expect(phoneA.getTransportHealthSnapshot()).toMatchObject({ listener: { state: 'ready' }, routes: [{ id: 'proximity', state: 'ready' }], }); }); + it('honours persisted Hidden through the real startup orchestrator', async () => { + const startup = orchestrate(phoneA, PHONE_A, false); + + await startup.start(); + + expect(startup.isDiscoverable()).toBe(false); + expect(nativeA.calls).toEqual(['start']); + expect(nativeA.advertising).toBe(false); + expect(discoveryRoute()).toMatchObject({ + browse: { state: 'ready' }, + advertise: { state: 'stopped' }, + }); + }); + + it('keeps orchestrator truth visible when native cannot stop, then retries', async () => { + const startup = orchestrate(phoneA, PHONE_A, true); + await startup.start(); + nativeA.stopAdvertisingFailure = new Error( + 'Multipeer refused to stop advertising.', + ); + + await expect(startup.setDiscoverable(false)).rejects.toThrow( + 'Multipeer refused to stop advertising.', + ); + + expect(startup.isDiscoverable()).toBe(true); + expect(nativeA.advertising).toBe(true); + expect(discoveryRoute().advertise.state).toBe('failed'); + + nativeA.stopAdvertisingFailure = undefined; + await startup.setDiscoverable(false); + + expect(startup.isDiscoverable()).toBe(false); + expect(nativeA.advertising).toBe(false); + expect(discoveryRoute().advertise.state).toBe('stopped'); + }); + + it('reports an advertising failure and retries without restarting browsing', async () => { + await phoneA.discovery.start(); + nativeA.startAdvertisingFailure = new Error( + 'Advertising needs local network permission.', + ); + + await expect(phoneA.discovery.advertise(PHONE_A)).rejects.toThrow( + 'Advertising needs local network permission.', + ); + + expect(nativeA.advertising).toBe(false); + expect(discoveryRoute().browse.state).toBe('ready'); + expect(discoveryRoute().advertise).toMatchObject({ + state: 'failed', + error: 'Advertising needs local network permission.', + }); + + nativeA.startAdvertisingFailure = undefined; + await phoneA.discovery.advertise(PHONE_A); + + expect(nativeA.advertising).toBe(true); + expect(discoveryRoute().advertise.state).toBe('ready'); + expect( + nativeA.calls.filter(call => call === 'startAdvertising'), + ).toHaveLength(2); + expect(nativeA.calls.filter(call => call === 'start')).toHaveLength(1); + }); + it('says why when the phone cannot advertise at all', async () => { nativeA.startFailure = new Error( 'Nearby Sync needs local network permission.', @@ -605,7 +705,7 @@ describe('two phones talking with no network between them', () => { await expect(phoneA.discovery.start()).rejects.toThrow(); nativeA.startFailure = undefined; - await phoneA.discovery.start(); + await startVisible(phoneA, PHONE_A); // The retry has to actually reach native again - a failed attempt left cached is a phone that never // recovers without a relaunch. @@ -626,7 +726,7 @@ describe('two phones talking with no network between them', () => { }); it('reports a rescan that failed without claiming the phone went down with it', async () => { - await phoneA.discovery.start(); + await startVisible(phoneA, PHONE_A); nativeA.rescanFailure = new Error('Browsing failed to restart.'); await expect(phoneA.discovery.rescan()).rejects.toThrow( @@ -652,7 +752,7 @@ describe('two phones talking with no network between them', () => { }); it('finds the phones again on a rescan', async () => { - await phoneB.discovery.start(); + await startVisible(phoneB, PHONE_B); const found: DiscoveredDevice[] = []; phoneA.discovery.onDeviceFound(device => found.push(device)); await phoneA.discovery.start(); @@ -679,7 +779,8 @@ describe('two phones talking with no network between them', () => { it('closes the connections it was holding and reports itself stopped', async () => { const inbound: SyncConnection[] = []; await phoneB.listen(0, connection => inbound.push(connection)); - await phoneA.discovery.start(); + await phoneB.discovery.advertise(PHONE_B); + await startVisible(phoneA, PHONE_A); const outbound = await phoneA.connect('', 0, PHONE_B); let closed = false; outbound.onClose(() => { @@ -700,8 +801,8 @@ describe('two phones talking with no network between them', () => { }); it('forgets the phones it had found', async () => { - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await startVisible(phoneA, PHONE_A); + await startVisible(phoneB, PHONE_B); await phoneA.stop(); @@ -755,27 +856,117 @@ describe('two phones talking with no network between them', () => { }); it('can be switched back on afterwards', async () => { - await phoneA.discovery.start(); + await startVisible(phoneA, PHONE_A); await phoneA.stop(); - await phoneA.discovery.start(); - await phoneB.discovery.start(); + await startVisible(phoneA, PHONE_A); + await startVisible(phoneB, PHONE_B); // A user toggling Nearby off and on is not a relaunch. It has to find the room again. expect(phoneA.canConnect(PHONE_B)).toBe(true); expect(phoneA.getTransportHealthSnapshot().listener.state).toBe('ready'); }); - it('leaves stopping advertising to the whole shutdown', async () => { - await phoneA.discovery.start(); + it('stops advertising without stopping nearby browsing or connections', async () => { + await startVisible(phoneA, PHONE_A); await expect(phoneA.discovery.stopAdvertising()).resolves.toBeUndefined(); - // Multipeer has no separate advertise session, so this is deliberately a no-op rather than a - // teardown - calling it must not make the phone unfindable. + expect(nativeA.advertising).toBe(false); + expect(nativeA.calls).toContain('stopAdvertising'); expect(phoneA.getTransportHealthSnapshot().listener.state).toBe('ready'); - await expect(phoneA.discovery.stop()).resolves.toBeUndefined(); - expect(nativeA.calls).not.toContain('stop'); + expect(discoveryRoute().advertise.state).toBe('stopped'); + }); + + it('keeps the real advertising state on a native failure and retries cleanly', async () => { + await startVisible(phoneA, PHONE_A); + nativeA.stopAdvertisingFailure = new Error( + 'Multipeer refused to stop advertising.', + ); + + await expect(phoneA.discovery.stopAdvertising()).rejects.toThrow( + 'Multipeer refused to stop advertising.', + ); + + expect(nativeA.advertising).toBe(true); + expect(discoveryRoute().advertise).toMatchObject({ + state: 'failed', + error: 'Multipeer refused to stop advertising.', + }); + + nativeA.stopAdvertisingFailure = undefined; + await phoneA.discovery.stopAdvertising(); + + expect(nativeA.advertising).toBe(false); + expect(discoveryRoute().advertise.state).toBe('stopped'); + expect( + nativeA.calls.filter(call => call === 'stopAdvertising'), + ).toHaveLength(2); + }); + + it('starts advertising again without restarting the whole nearby session', async () => { + await startVisible(phoneA, PHONE_A); + await phoneA.discovery.stopAdvertising(); + + await phoneA.discovery.advertise(PHONE_A); + + expect(nativeA.advertising).toBe(true); + expect(nativeA.calls.filter(call => call === 'start')).toHaveLength(1); + expect(nativeA.calls).toContain('startAdvertising'); + expect(discoveryRoute().advertise.state).toBe('ready'); + }); + + it('honours a hide request that arrives while native advertising starts', async () => { + let releaseAdvertising: () => void = () => {}; + let markAdvertisingStarted: () => void = () => {}; + nativeA.startAdvertisingBarrier = new Promise(resolve => { + releaseAdvertising = resolve; + }); + const advertisingStarted = new Promise(resolve => { + markAdvertisingStarted = resolve; + }); + nativeA.onStartAdvertising = markAdvertisingStarted; + + const show = phoneA.discovery.advertise(PHONE_A); + await advertisingStarted; + const hide = phoneA.discovery.stopAdvertising(); + releaseAdvertising(); + + await Promise.all([show, hide]); + + expect(nativeA.advertising).toBe(false); + expect(nativeA.calls).toEqual([ + 'start', + 'startAdvertising', + 'stopAdvertising', + ]); + expect(discoveryRoute().advertise.state).toBe('stopped'); + }); + + it('honours a full shutdown requested before queued advertising starts', async () => { + let releaseAdvertising: () => void = () => {}; + let markAdvertisingStarted: () => void = () => {}; + nativeA.startAdvertisingBarrier = new Promise(resolve => { + releaseAdvertising = resolve; + }); + const advertisingStarted = new Promise(resolve => { + markAdvertisingStarted = resolve; + }); + nativeA.onStartAdvertising = markAdvertisingStarted; + + const show = phoneA.discovery.advertise(PHONE_A); + const shutdown = phoneA.stop(); + await advertisingStarted; + releaseAdvertising(); + + await Promise.all([show, shutdown]); + + expect(nativeA.started).toBe(false); + expect(nativeA.advertising).toBe(false); + expect(nativeA.calls).toEqual(['start', 'startAdvertising', 'stop']); + expect(phoneA.getTransportHealthSnapshot().listener.state).toBe( + 'stopped', + ); }); }); diff --git a/__tests__/unit/sync/pairingEntitlementCredentialAdapter.test.ts b/__tests__/unit/sync/pairingEntitlementCredentialAdapter.test.ts index 1af4a25b5..88791a47f 100644 --- a/__tests__/unit/sync/pairingEntitlementCredentialAdapter.test.ts +++ b/__tests__/unit/sync/pairingEntitlementCredentialAdapter.test.ts @@ -163,6 +163,7 @@ describe('two devices agreeing about a shared licence', () => { key: FULL_LICENCE_KEY, entitlementId: fullLicenceId, expiry: null, + tier: null, verifiedAt: 1_700_000_000_000, }); } @@ -212,6 +213,7 @@ describe('two devices agreeing about a shared licence', () => { key: LICENCE_KEY, entitlementId: licenceId, expiry: null, + tier: null, verifiedAt: 1_700_000_000_000, }); } diff --git a/__tests__/unit/utils/progressPresentation.test.ts b/__tests__/unit/utils/progressPresentation.test.ts new file mode 100644 index 000000000..97b63e384 --- /dev/null +++ b/__tests__/unit/utils/progressPresentation.test.ts @@ -0,0 +1,57 @@ +import { + formatByteRate, + presentProgress, +} from '../../../src/utils/progressPresentation'; + +describe('Mobile progress presentation', () => { + it('shows known bytes, live rate, and a finite percentage', () => { + const result = presentProgress({ + bytesDownloaded: 5 * 1024 * 1024, + totalBytes: 20 * 1024 * 1024, + bytesPerSecond: 2.5 * 1024 * 1024, + status: 'running', + }); + + expect(result.percentageText).toBe('25%'); + expect(result.bytesText).toBe('5 MB / 20 MB'); + expect(result.rateText).toBe('2.5 MB/s'); + expect(result.detailText).toBe('5 MB / 20 MB · 2.5 MB/s'); + }); + + it('keeps an unknown total and rate honest without NaN', () => { + const result = presentProgress({ + bytesDownloaded: 64, + totalBytes: 0, + bytesPerSecond: Number.NaN, + progress: Number.POSITIVE_INFINITY, + status: 'running', + }); + + expect(result.percentageText).toBeUndefined(); + expect(result.bytesText).toBe('64 B'); + expect(result.rateText).toBe('Rate unavailable'); + expect(result.detailText).toBe('64 B · Rate unavailable'); + expect(JSON.stringify(result)).not.toContain('NaN'); + expect(JSON.stringify(result)).not.toContain('Infinity'); + }); + + it.each([ + ['completed', '100%'], + ['failed', '30%'], + ['cancelled', '30%'], + ])('renders terminal %s progress without an active-only value', (status, expected) => { + const result = presentProgress({ + bytesDownloaded: 300, + totalBytes: 1_000, + status, + }); + expect(result.percentageText).toBe(expected); + expect(result.progress.terminal).toBe(true); + }); + + it('never formats an invalid rate', () => { + expect(formatByteRate(Number.NaN)).toBe('Rate unavailable'); + expect(formatByteRate(Number.POSITIVE_INFINITY)).toBe('Rate unavailable'); + expect(formatByteRate(-1)).toBe('Rate unavailable'); + }); +}); diff --git a/__tests__/unit/utils/sharePrompt.test.ts b/__tests__/unit/utils/sharePrompt.test.ts index 3b9c536cf..a9e8f920e 100644 --- a/__tests__/unit/utils/sharePrompt.test.ts +++ b/__tests__/unit/utils/sharePrompt.test.ts @@ -1,10 +1,10 @@ -import { Linking } from 'react-native'; +import { Linking, Platform } from 'react-native'; import { maybeScheduleSharePrompt, resetSharePromptSession, subscribeSharePrompt, emitSharePrompt, - shareOnX, + rateOnStore, } from '../../../src/utils/sharePrompt'; describe('maybeScheduleSharePrompt — at most once per session', () => { @@ -56,20 +56,42 @@ describe('maybeScheduleSharePrompt — at most once per session', () => { }); }); -describe('shareOnX', () => { +describe('rateOnStore', () => { const openURL = Linking.openURL as jest.Mock; + const canOpenURL = Linking.canOpenURL as jest.Mock; + const originalPlatform = Platform.OS; beforeEach(() => { openURL.mockReset().mockResolvedValue(undefined); + canOpenURL.mockReset().mockResolvedValue(false); }); - it('opens the X web intent prefilled with the share text, ready to post', async () => { - await shareOnX(); - expect(openURL).toHaveBeenCalledTimes(1); - const url = openURL.mock.calls[0][0]; - expect(url).toMatch(/^https:\/\/x\.com\/intent\/post\?text=/); - expect(decodeURIComponent(url)).toContain('Off Grid AI is background intelligence'); - expect(decodeURIComponent(url)).toContain('getoffgridai.co/early-access'); + afterAll(() => { + Object.defineProperty(Platform, 'OS', { configurable: true, value: originalPlatform }); + }); + + it('opens the App Store review page on iOS', async () => { + Object.defineProperty(Platform, 'OS', { configurable: true, value: 'ios' }); + await rateOnStore(); + expect(openURL).toHaveBeenCalledWith( + 'https://apps.apple.com/app/id6759299882?action=write-review', + ); + expect(canOpenURL).not.toHaveBeenCalled(); + }); + + it('opens the Play Store app on Android when it is available', async () => { + Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' }); + canOpenURL.mockResolvedValue(true); + await rateOnStore(); + expect(openURL).toHaveBeenCalledWith('market://details?id=ai.offgridmobile'); + }); + + it('opens the Play Store web page when the Android app is unavailable', async () => { + Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' }); + await rateOnStore(); + expect(openURL).toHaveBeenCalledWith( + 'https://play.google.com/store/apps/details?id=ai.offgridmobile', + ); }); }); diff --git a/__tests__/utils/factories.ts b/__tests__/utils/factories.ts index 41077d386..86ebd5084 100644 --- a/__tests__/utils/factories.ts +++ b/__tests__/utils/factories.ts @@ -5,7 +5,7 @@ * Use these factories to create consistent test data across all test files. */ -import type { ImageDownloadDeps } from '../../src/screens/ModelsScreen/imageDownloadActions'; +import type { ImageDownloadDeps } from '../../src/services/imageDownloadActions'; import { Message, Conversation, diff --git a/__tests__/utils/nativeSyncBoundaries.ts b/__tests__/utils/nativeSyncBoundaries.ts index b5b3761ba..626a8a299 100644 --- a/__tests__/utils/nativeSyncBoundaries.ts +++ b/__tests__/utils/nativeSyncBoundaries.ts @@ -54,6 +54,7 @@ export interface TcpDialRecord { } let dials: TcpDialRecord[] = []; +const routedPorts = new Map(); export function getTcpDials(): readonly TcpDialRecord[] { return dials; @@ -63,6 +64,18 @@ export function resetTcpDials(): void { dials = []; } +/** Route one advertised port to the listener that represents a different fake host. */ +export function routeTcpPort( + advertisedPort: number, + listenerPort: number, +): void { + routedPorts.set(advertisedPort, listenerPort); +} + +export function resetTcpPortRoutes(): void { + routedPorts.clear(); +} + export function createNativeTcpBoundary(): RnTcpModule { const servers = new Map void>(); let nextPort = 43000; @@ -85,7 +98,8 @@ export function createNativeTcpBoundary(): RnTcpModule { return server; }, createConnection(options, callback) { - const onConnection = servers.get(options.port); + const listenerPort = routedPorts.get(options.port) ?? options.port; + const onConnection = servers.get(listenerPort); if (!onConnection) { // Recorded before throwing: a dial to a port nothing is listening on is a real outcome, and a // test that only sees the throw cannot tell it apart from a dial that never happened. diff --git a/__tests__/utils/proximityNativeBoundary.ts b/__tests__/utils/proximityNativeBoundary.ts index 197a64dfe..c7bee7bcc 100644 --- a/__tests__/utils/proximityNativeBoundary.ts +++ b/__tests__/utils/proximityNativeBoundary.ts @@ -38,10 +38,15 @@ export { FakeNativeEventEmitter as ProximityEventEmitter } from './nativeEventBu export class ProximityNativeFake extends NativeEventBus { /** Set by a test to make the native layer refuse, the way a device with Bluetooth off does. */ startFailure: Error | undefined; + startAdvertisingFailure: Error | undefined; + startAdvertisingBarrier: Promise | undefined; + onStartAdvertising: (() => void) | undefined; + stopAdvertisingFailure: Error | undefined; rescanFailure: Error | undefined; connectFailure: Error | undefined; readonly calls: string[] = []; started = false; + advertising = false; device: Device; constructor(private readonly air: ProximityAir, device: Device) { @@ -54,9 +59,26 @@ export class ProximityNativeFake extends NativeEventBus { if (this.startFailure) throw this.startFailure; this.device = device; this.started = true; + this.advertising = false; this.air.announce(this); } + async startAdvertising(): Promise { + this.calls.push('startAdvertising'); + this.onStartAdvertising?.(); + if (this.startAdvertisingFailure) throw this.startAdvertisingFailure; + await this.startAdvertisingBarrier; + this.advertising = true; + this.air.announce(this); + } + + async stopAdvertising(): Promise { + this.calls.push('stopAdvertising'); + if (this.stopAdvertisingFailure) throw this.stopAdvertisingFailure; + this.advertising = false; + this.air.withdraw(this); + } + async rescan(): Promise { this.calls.push('rescan'); if (this.rescanFailure) throw this.rescanFailure; @@ -66,6 +88,7 @@ export class ProximityNativeFake extends NativeEventBus { async stop(): Promise { this.calls.push('stop'); this.started = false; + this.advertising = false; this.air.withdraw(this); } @@ -121,8 +144,12 @@ export class ProximityAir { for (const peer of this.devices) { if (peer === source || !peer.started) continue; // Both directions, the way browsing and advertising each surface the other side. - peer.emit(PEER_FOUND_EVENT, { device: source.device }); - source.emit(PEER_FOUND_EVENT, { device: peer.device }); + if (source.advertising) { + peer.emit(PEER_FOUND_EVENT, { device: source.device }); + } + if (peer.advertising) { + source.emit(PEER_FOUND_EVENT, { device: peer.device }); + } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 204bcfe56..68b6bcf7e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -44,6 +44,8 @@ the app is backgrounded / the screen is off (WorkManager foreground worker). --> + + @@ -132,7 +134,7 @@ android:name="ai.offgridmobile.sync.MeshResidencyService" android:enabled="true" android:exported="false" - android:foregroundServiceType="dataSync" /> + android:foregroundServiceType="connectedDevice" /> diff --git a/android/app/src/main/java/ai/offgridmobile/sync/BlobChannelModule.kt b/android/app/src/main/java/ai/offgridmobile/sync/BlobChannelModule.kt index 0c469ab44..67c46808b 100644 --- a/android/app/src/main/java/ai/offgridmobile/sync/BlobChannelModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/sync/BlobChannelModule.kt @@ -35,6 +35,23 @@ class BlobChannelModule( work.execute { promise.resolve(BlobCrypto.lanAddress()) } } + /** All current IPv4 interfaces; the shared QR projector decides which routes are safe. */ + @ReactMethod + fun interfaceCandidates(promise: Promise) { + work.execute { + val result = Arguments.createArray() + BlobCrypto.interfaceCandidates().forEach { candidate -> + result.pushMap( + Arguments.createMap().apply { + putString("host", candidate.host) + putString("interfaceName", candidate.interfaceName) + }, + ) + } + promise.resolve(result) + } + } + /** * Offer an endpoint for one transfer, and answer the url a peer should stream to. * diff --git a/android/app/src/main/java/ai/offgridmobile/sync/BlobCrypto.kt b/android/app/src/main/java/ai/offgridmobile/sync/BlobCrypto.kt index bae81ec33..53be5faed 100644 --- a/android/app/src/main/java/ai/offgridmobile/sync/BlobCrypto.kt +++ b/android/app/src/main/java/ai/offgridmobile/sync/BlobCrypto.kt @@ -12,6 +12,16 @@ import java.net.NetworkInterface * derivation defined in the shared sync package, and hands it down as bytes. */ object BlobCrypto { + data class InterfaceCandidate( + val host: String, + val interfaceName: String, + val isUp: Boolean, + val isLoopback: Boolean, + val isLinkLocal: Boolean, + val isAnyLocal: Boolean, + val isMulticast: Boolean, + ) + fun decode(value: String): ByteArray = Base64.decode(value, Base64.DEFAULT) /** @@ -34,4 +44,46 @@ object BlobCrypto { } return null } + + /** Current numeric IPv4 interfaces. Shared sync code owns route safety and classification. */ + fun interfaceCandidates(): List = + runCatching { + val records = mutableListOf() + val interfaces = NetworkInterface.getNetworkInterfaces() ?: return@runCatching emptyList() + while (interfaces.hasMoreElements()) { + val networkInterface = interfaces.nextElement() + val addresses = networkInterface.inetAddresses + while (addresses.hasMoreElements()) { + val address = addresses.nextElement() + if (address !is Inet4Address) continue + records += + InterfaceCandidate( + host = address.hostAddress ?: continue, + interfaceName = networkInterface.name, + isUp = networkInterface.isUp, + isLoopback = networkInterface.isLoopback || address.isLoopbackAddress, + isLinkLocal = address.isLinkLocalAddress, + isAnyLocal = address.isAnyLocalAddress, + isMulticast = address.isMulticastAddress, + ) + } + } + usableInterfaceCandidates(records) + }.getOrDefault(emptyList()) + + internal fun usableInterfaceCandidates( + records: List, + ): List = + records + .asSequence() + .filter { record -> + record.isUp && + !record.isLoopback && + !record.isLinkLocal && + !record.isAnyLocal && + !record.isMulticast + } + .distinctBy { record -> "${record.interfaceName}\u0000${record.host}" } + .sortedWith(compareBy(InterfaceCandidate::interfaceName, InterfaceCandidate::host)) + .toList() } diff --git a/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyModule.kt b/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyModule.kt index 3e4c65f54..824bf90be 100644 --- a/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyModule.kt @@ -1,9 +1,11 @@ package ai.offgridmobile.sync import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.WritableMap /** * Android half of the mesh residency contract (see src/services/sync/nativeMeshResidency.ts). @@ -14,9 +16,8 @@ import com.facebook.react.bridge.ReactMethod */ class MeshResidencyModule( private val reactContext: ReactApplicationContext, + private val snapshotMapFactory: () -> WritableMap = { Arguments.createMap() }, ) : ReactContextBaseJavaModule(reactContext) { - private var held = false - override fun getName(): String = "MeshResidencyModule" override fun getConstants(): Map = @@ -30,30 +31,30 @@ class MeshResidencyModule( @ReactMethod fun begin(promise: Promise) { try { - if (!held) { - MeshResidencyService.start(reactContext) - held = true + MeshResidencyService.start(reactContext) { snapshot -> + promise.resolve(snapshot.toWritableMap(snapshotMapFactory())) } - promise.resolve(null) } catch (e: IllegalStateException) { // Android throws when a foreground service is started from a disallowed state (for // example a background start without an exemption). Report it rather than crashing: the // mesh still works in the foreground. - held = false promise.reject("mesh_residency_denied", e) } catch (e: SecurityException) { - held = false promise.reject("mesh_residency_denied", e) } } + @ReactMethod + fun state(promise: Promise) { + promise.resolve( + MeshResidencyService.currentSnapshot().toWritableMap(snapshotMapFactory()), + ) + } + @ReactMethod fun end(promise: Promise) { try { - if (held) { - MeshResidencyService.stop(reactContext) - held = false - } + MeshResidencyService.stop(reactContext) promise.resolve(null) } catch (e: IllegalStateException) { promise.reject("mesh_residency_stop_failed", e) @@ -63,10 +64,7 @@ class MeshResidencyModule( override fun invalidate() { // A reload or teardown must not leave an orphan notification promising reachability the // JS engine can no longer provide. - if (held) { - MeshResidencyService.stop(reactContext) - held = false - } + MeshResidencyService.stop(reactContext) super.invalidate() } } diff --git a/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyService.kt b/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyService.kt index 931a90ace..3f7ea926e 100644 --- a/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyService.kt +++ b/android/app/src/main/java/ai/offgridmobile/sync/MeshResidencyService.kt @@ -1,5 +1,6 @@ package ai.offgridmobile.sync +import android.app.ForegroundServiceStartNotAllowedException import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -9,24 +10,70 @@ import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder +import android.os.Handler +import android.os.Looper import androidx.core.app.NotificationCompat +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import java.util.concurrent.atomic.AtomicBoolean /** * Keeps the Personal Mesh reachable while Off Grid is not in the foreground. * * Without this, Android suspends the process and mDNS discovery, the TCP listener and any in-flight - * transfer stop, while the other device still shows this one as connected. A dataSync foreground - * service is the only way to hold those sockets open, and it comes with a notification the user can - * see - which is the honest trade: background reachability is visible, never silent. + * transfer stop, while the other device still shows this one as connected. Android classifies this + * live local-device connection as connectedDevice work. That type is not subject to dataSync's + * six-hour budget. The ongoing notification keeps background reachability visible, never silent. */ class MeshResidencyService : Service() { override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - startForegroundCompat() - // Restart if the OS kills us for memory, so the mesh comes back without the user - // reopening the app. No redelivered intent is needed - residency carries no payload. - return START_STICKY + val generation = intent?.getLongExtra(EXTRA_GENERATION, -1L) ?: -1L + if (!isCurrentGeneration(generation)) { + stopSelf(startId) + return START_NOT_STICKY + } + try { + startForegroundCompat() + publish(generation, ResidencySnapshot("background")) + } catch (error: RuntimeException) { + val startWasDenied = + error is SecurityException || + (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + error is ForegroundServiceStartNotAllowedException) + if (!startWasDenied) throw error + + // Promotion can be denied after startForegroundService() has already returned. Stop + // this service now so Android does not later kill the process for a late promotion. + publish( + generation, + ResidencySnapshot("foreground_only", "promotion_denied"), + ) + stopImmediately() + } + + // The React Native mesh engine owns the sockets. A service-only restart would show a false + // reachability notification and can also occur when Android does not permit a new FGS. + return START_NOT_STICKY + } + + /** Android gives a timed foreground service only a few seconds to stop after this callback. */ + override fun onTimeout(startId: Int, fgsType: Int) { + publishCurrent(ResidencySnapshot("foreground_only", "timed_out")) + stopImmediately() + } + + private fun stopImmediately() { + residencyRequested.set(false) + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onDestroy() { + residencyRequested.set(false) + markUnexpectedStop() + super.onDestroy() } private fun startForegroundCompat() { @@ -35,7 +82,7 @@ class MeshResidencyService : Service() { startForeground( NOTIFICATION_ID, notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + FOREGROUND_SERVICE_TYPE, ) } else { startForeground(NOTIFICATION_ID, notification) @@ -45,6 +92,28 @@ class MeshResidencyService : Service() { companion object { const val CHANNEL_ID = "offgrid-personal-mesh" const val NOTIFICATION_ID = 4711 + const val FOREGROUND_SERVICE_TYPE = + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + private const val EXTRA_GENERATION = "mesh_residency_generation" + private const val PROMOTION_DEADLINE_MS = 5_000L + private val residencyRequested = AtomicBoolean(false) + private val mainHandler = Handler(Looper.getMainLooper()) + private val lock = Any() + private var activeGeneration = 0L + private var snapshot = ResidencySnapshot("inactive") + private val waiters = mutableListOf<(ResidencySnapshot) -> Unit>() + + data class ResidencySnapshot( + val status: String, + val reason: String? = null, + ) { + /** Project service state into the only map type that can cross the React Native bridge. */ + fun toWritableMap(target: WritableMap = Arguments.createMap()): WritableMap = + target.apply { + putString("status", status) + if (reason == null) putNull("reason") else putString("reason", reason) + } + } /** * Ensure the channel exists before the first foreground start. @@ -78,18 +147,97 @@ class MeshResidencyService : Service() { .setPriority(NotificationCompat.PRIORITY_LOW) .build() - fun start(context: Context) { - ensureChannel(context) - val intent = Intent(context, MeshResidencyService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) + fun start(context: Context, onResult: (ResidencySnapshot) -> Unit) { + val generation: Long + synchronized(lock) { + if (snapshot.status == "background") { + onResult(snapshot) + return + } + if (snapshot.status == "starting") { + waiters.add(onResult) + return + } + activeGeneration += 1 + generation = activeGeneration + snapshot = ResidencySnapshot("starting") + waiters.add(onResult) + } + residencyRequested.set(true) + try { + ensureChannel(context) + val intent = + Intent(context, MeshResidencyService::class.java) + .putExtra(EXTRA_GENERATION, generation) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (_: RuntimeException) { + residencyRequested.set(false) + publish( + generation, + ResidencySnapshot("foreground_only", "promotion_denied"), + ) } + mainHandler.postDelayed({ + val timedOut = publish( + generation, + ResidencySnapshot("foreground_only", "promotion_timeout"), + onlyIfStarting = true, + ) + if (timedOut) { + residencyRequested.set(false) + context.stopService(Intent(context, MeshResidencyService::class.java)) + } + }, PROMOTION_DEADLINE_MS) } fun stop(context: Context) { + residencyRequested.set(false) + synchronized(lock) { + activeGeneration += 1 + snapshot = ResidencySnapshot("inactive") + val pending = waiters.toList() + waiters.clear() + pending.forEach { it(snapshot) } + } context.stopService(Intent(context, MeshResidencyService::class.java)) } + + fun currentSnapshot(): ResidencySnapshot = synchronized(lock) { snapshot } + + private fun isCurrentGeneration(generation: Long): Boolean = + synchronized(lock) { generation == activeGeneration && snapshot.status == "starting" } + + private fun publishCurrent(next: ResidencySnapshot) { + val generation = synchronized(lock) { activeGeneration } + publish(generation, next) + } + + private fun markUnexpectedStop() { + val generation = synchronized(lock) { + if (snapshot.status == "background") activeGeneration else null + } ?: return + publish(generation, ResidencySnapshot("foreground_only", "unavailable")) + } + + private fun publish( + generation: Long, + next: ResidencySnapshot, + onlyIfStarting: Boolean = false, + ): Boolean { + val pending: List<(ResidencySnapshot) -> Unit> + synchronized(lock) { + if (generation != activeGeneration) return false + if (onlyIfStarting && snapshot.status != "starting") return false + snapshot = next + pending = waiters.toList() + waiters.clear() + } + pending.forEach { it(next) } + return true + } } } diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml index 71797e32a..ef2966388 100644 --- a/android/app/src/main/res/xml/network_security_config.xml +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -10,9 +10,10 @@ NOTE: Android network_security_config.xml has no IP-range wildcard support — only matches exact hostnames or IPs, not CIDR ranges. The base-config is therefore the only practical mechanism to allow HTTP to user-configured LAN servers with arbitrary IPs - (192.168.x.x, 10.x.x.x, 172.16-31.x.x). All outbound connections to the public internet - remain HTTPS-only because those servers redirect HTTP → HTTPS; this config only permits - plain HTTP but does not downgrade secure connections. --> + (192.168.x.x, 10.x.x.x, 172.16-31.x.x) and Tailscale (100.64.0.0/10). + Android cannot express private IP ranges here. The remote transport policy therefore + rejects public HTTP in JavaScript, strips stored credentials from allowed LAN HTTP, and + rejects redirects. This base permission only lets the validated LAN request reach Android. --> diff --git a/android/app/src/test/java/ai/offgridmobile/sync/BlobCryptoInterfaceCandidatesTest.kt b/android/app/src/test/java/ai/offgridmobile/sync/BlobCryptoInterfaceCandidatesTest.kt new file mode 100644 index 000000000..b425ed85f --- /dev/null +++ b/android/app/src/test/java/ai/offgridmobile/sync/BlobCryptoInterfaceCandidatesTest.kt @@ -0,0 +1,55 @@ +package ai.offgridmobile.sync + +import org.junit.Assert.assertEquals +import org.junit.Test + +class BlobCryptoInterfaceCandidatesTest { + @Test + fun usableCandidatesKeepOnlyActiveUnicastInterfacesAndPreserveNames() { + val candidates = + listOf( + record("wlan0", "192.168.1.10"), + record("tun0", "100.80.1.2"), + record("lo", "127.0.0.1", isLoopback = true), + record("wlan0", "169.254.2.3", isLinkLocal = true), + record("down0", "10.0.0.4", isUp = false), + record("any0", "0.0.0.0", isAnyLocal = true), + record("cast0", "224.0.0.1", isMulticast = true), + ) + + assertEquals( + listOf(record("tun0", "100.80.1.2"), record("wlan0", "192.168.1.10")), + BlobCrypto.usableInterfaceCandidates(candidates), + ) + } + + @Test + fun usableCandidatesDeduplicatePerInterfaceWithoutCollapsingDifferentInterfaces() { + val candidate = record("tun0", "100.64.0.9") + + assertEquals( + listOf(candidate, record("tun1", "100.64.0.9")), + BlobCrypto.usableInterfaceCandidates( + listOf(candidate, candidate, record("tun1", "100.64.0.9")), + ), + ) + } + + private fun record( + interfaceName: String, + host: String, + isUp: Boolean = true, + isLoopback: Boolean = false, + isLinkLocal: Boolean = false, + isAnyLocal: Boolean = false, + isMulticast: Boolean = false, + ) = BlobCrypto.InterfaceCandidate( + host = host, + interfaceName = interfaceName, + isUp = isUp, + isLoopback = isLoopback, + isLinkLocal = isLinkLocal, + isAnyLocal = isAnyLocal, + isMulticast = isMulticast, + ) +} diff --git a/android/app/src/test/java/ai/offgridmobile/sync/MeshResidencyServiceTest.kt b/android/app/src/test/java/ai/offgridmobile/sync/MeshResidencyServiceTest.kt new file mode 100644 index 000000000..d27b696dd --- /dev/null +++ b/android/app/src/test/java/ai/offgridmobile/sync/MeshResidencyServiceTest.kt @@ -0,0 +1,188 @@ +package ai.offgridmobile.sync + +import android.Manifest +import android.app.Application +import android.app.Service +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import androidx.test.core.app.ApplicationProvider +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableMap +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class MeshResidencyServiceTest { + @After + fun releaseResidency() { + val context = ApplicationProvider.getApplicationContext() + MeshResidencyService.stop(context) + } + + @Test + fun personalMeshUsesConnectedDeviceForegroundServiceContract() { + val context = ApplicationProvider.getApplicationContext() + val serviceInfo = + context.packageManager.getServiceInfo( + ComponentName(context, MeshResidencyService::class.java), + PackageManager.ComponentInfoFlags.of(0), + ) + + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, + MeshResidencyService.FOREGROUND_SERVICE_TYPE, + ) + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE, + serviceInfo.foregroundServiceType, + ) + + val requestedPermissions = + context.packageManager + .getPackageInfo( + context.packageName, + PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()), + ).requestedPermissions.orEmpty() + + assertTrue( + requestedPermissions.contains(Manifest.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE), + ) + assertTrue( + requestedPermissions.contains(Manifest.permission.CHANGE_WIFI_MULTICAST_STATE), + ) + } + + @Test + fun beginResolvesAReactNativeWritableMapAfterTheServicePublishesBackground() { + val context = ApplicationProvider.getApplicationContext() + MeshResidencyService.start(context) {} + val startIntent = shadowOf(context as Application).nextStartedService + val service = + org.robolectric.Robolectric + .buildService(MeshResidencyService::class.java) + .create() + .get() + service.onStartCommand(startIntent, 0, 16) + + val reactContext = mock(ReactApplicationContext::class.java) + val promise = mock(Promise::class.java) + val module = MeshResidencyModule(reactContext) { JavaOnlyMap() } + + module.begin(promise) + + val resolved = ArgumentCaptor.forClass(Any::class.java) + verify(promise).resolve(resolved.capture()) + val result = resolved.value as WritableMap + assertEquals("background", result.getString("status")) + assertTrue(result.isNull("reason")) + } + + @Test + @Config(sdk = [34], application = Application::class) + fun timeoutStopsResidencyImmediately() { + val context = ApplicationProvider.getApplicationContext() + var startedStatus: String? = null + MeshResidencyService.start(context) { startedStatus = it.status } + val startIntent = shadowOf(context as Application).nextStartedService + val service = + org.robolectric.Robolectric + .buildService(MeshResidencyService::class.java) + .create() + .get() + + service.onStartCommand(startIntent, 0, 17) + assertEquals("background", startedStatus) + service.onTimeout(17, MeshResidencyService.FOREGROUND_SERVICE_TYPE) + + val shadow = shadowOf(service) + assertTrue(shadow.isForegroundStopped) + assertTrue(shadow.notificationShouldRemoved) + assertTrue(shadow.isStoppedBySelf) + + var restartedStatus: String? = null + MeshResidencyService.start(context) { restartedStatus = it.status } + assertNotNull(shadowOf(context as Application).nextStartedService) + assertEquals("starting", MeshResidencyService.currentSnapshot().status) + assertEquals(null, restartedStatus) + } + + @Test + fun serviceDoesNotRestartWithoutItsMeshOwner() { + val service = + org.robolectric.Robolectric + .buildService(MeshResidencyService::class.java) + .create() + .get() + + val restartMode = service.onStartCommand(null, 0, 18) + + assertEquals(Service.START_NOT_STICKY, restartMode) + } + + @Test + fun timeoutIsProjectedAsForegroundOnlyInsteadOfBackgroundRunning() { + val context = ApplicationProvider.getApplicationContext() + MeshResidencyService.start(context) {} + val startIntent = shadowOf(context as Application).nextStartedService + val service = + org.robolectric.Robolectric + .buildService(MeshResidencyService::class.java) + .create() + .get() + + service.onStartCommand(startIntent, 0, 19) + service.onTimeout(19, MeshResidencyService.FOREGROUND_SERVICE_TYPE) + + assertEquals("foreground_only", MeshResidencyService.currentSnapshot().status) + assertEquals("timed_out", MeshResidencyService.currentSnapshot().reason) + } + + @Test + fun stopPublishesInactiveToAPendingBeginWaiter() { + val context = ApplicationProvider.getApplicationContext() + var resultStatus: String? = null + var resultReason: String? = "not_resolved" + + MeshResidencyService.start(context) { + resultStatus = it.status + resultReason = it.reason + } + MeshResidencyService.stop(context) + + assertEquals("inactive", resultStatus) + assertEquals(null, resultReason) + } + + @Test + fun unexpectedServiceStopRemovesTheBackgroundReachabilityClaim() { + val context = ApplicationProvider.getApplicationContext() + MeshResidencyService.start(context) {} + val startIntent = shadowOf(context as Application).nextStartedService + val controller = + org.robolectric.Robolectric + .buildService(MeshResidencyService::class.java) + .create() + val service = controller.get() + + service.onStartCommand(startIntent, 0, 20) + controller.destroy() + + assertEquals("foreground_only", MeshResidencyService.currentSnapshot().status) + assertEquals("unavailable", MeshResidencyService.currentSnapshot().reason) + } +} diff --git a/android/build.gradle b/android/build.gradle index 8fb686abb..35991036b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,6 +6,11 @@ buildscript { targetSdkVersion = 36 ndkVersion = "27.1.12297006" kotlinVersion = "2.2.0" + // react-native-inappbrowser-reborn otherwise requests dynamic `+` + // versions. Pin them so Gradle does not query unrelated repositories + // for metadata during an offline/local build. + androidXAnnotationVersion = "1.5.0" + androidXBrowserVersion = "1.4.0" } repositories { google() @@ -37,4 +42,3 @@ subprojects { subproject -> } } } - diff --git a/docs/GAPS_BACKLOG.md b/docs/GAPS_BACKLOG.md index 141a71c6f..bcf1484fe 100644 --- a/docs/GAPS_BACKLOG.md +++ b/docs/GAPS_BACKLOG.md @@ -12,6 +12,35 @@ Verdict legend: --- +## Active Kokoro voice-model download cannot stop at Pro expiry - 2026-08-26 + +**Verdict: instrument-and-revisit.** + +The Pro-expiry teardown stops audio, removes the voice download provider, and releases the TTS +engine. However, a Kokoro asset fetch that is already active continues inside the +`react-native-executorch` fetcher because that external API has no abort or cancel operation. +`pro/audio/ttsDownloadProvider.ts` records this boundary as `cancel: false`; expiry can prevent new +paid work, but it cannot stop the active native download or its remaining disk writes. + +Revisit when the native fetcher exposes cancellation, or put voice-model transfer behind an app-owned +cancellable downloader. The acceptance case is that exact Pro expiry aborts an active voice-model +network request and no download progress or file write occurs after access closes. + +--- + +## Android voice session can lose playback or transcription - 2026-08-24 + +**Verdict: instrument-and-revisit.** + +A Pixel 8a user reported that voice replies showed both transcripts but produced no sound. After the +user stopped and restarted the app, playback worked, but microphone input no longer produced a +transcript. The currently attached Android device creates and plays a full-volume `AudioTrack`, so +the failure is not reproduced. Capture Pixel logs for the playback and recorder state machines before +changing audio-focus or model-lifecycle behavior. Acceptance: repeated voice turns continue to play +and transcribe before and after app restart, for the user's selected language. + +--- + ## Projects screen does not refresh after desktop project sync - 2026-08-20 **Verdict: instrument-and-revisit.** @@ -1215,3 +1244,158 @@ these fixes are verified against faked native leaves. Three flows to run on a ph **Do not make `useVoiceSessionDriver` level-triggered again.** `voiceSession.dispatch` notifies on a phase change so the hero can show "Recording you now"; with a level-triggered driver that same notification opens a second recording mid-turn. The two belong together and each says so in a comment. + +--- + +## Personal Mesh visibility needs the final physical lifecycle pass + +**Status:** automation-backed; manual device verification is open. Filed 2026-08-24. + +The Shared, React Native, Pro control, and Swift tests prove that browsing and advertising are +separate. They also prove that Hidden is applied before startup, a failed advertising stop keeps the +last true runtime and stored state, overlapping show and hide requests finish in order, and a retry +can complete the stop. + +The remaining boundary is a real iPhone and Mac. Use the exact release builds and complete rows +43-48 in `docs/PERSONAL_MESH_TEST_MATRIX.csv`. Confirm that Hidden survives a cold start, that each +visibility control leaves the other function active, that an existing encrypted session stays active, +and that a second device sees the correct advertisement. Also confirm one private IP or machine-name +route and one non-default Sync port on every device. + +Close this gap only with the device names, OS versions, exact build commits, and the completed matrix +rows. Simulator and injected-failure results do not close the physical radio boundary. + +--- + +## Remote task controls need acknowledged, bounded Mobile state + +**Status:** code resolved 2026-08-28; physical iOS and Android verification pending. + +Commits `ddd59dc0` and `cba88680` keep Mobile as a subscriber to the Desktop-owned task state: + +- Task controls stay pending only until a matching authoritative `controlId` and control kind arrive. +- Applied results clear the request. Rejected results show the Desktop reason. A 15-second wait shows + that Desktop did not confirm; it does not change task state. +- Unrelated task updates cannot settle a control. +- Mobile does not render or decide Web Use or Computer Use approvals. Chat tasks start directly, and + non-task ActionApproval behavior remains separate. +- One router serves both task tools. It selects a stable eligible Desktop when no target is given, + resolves an exact case-insensitive device name or alias from `execution_device`, and never falls back + when a named Desktop is offline or disabled. + +Local evidence: Mobile and Mobile Pro typecheck and ESLint pass; seven focused suites pass 71/71, +covering both task kinds, applied, rejected, unrelated, and timed-out controls, two-Desktop default and +named routing, disabled/offline rejection, chat rendering, and non-task approval preservation. Keep +this entry open only for the final narrow and wide physical-device checks on iOS and Android. + +--- + +## RESOLVED 2026-08-29: a fresh Android device reported the license server as unavailable + +The connected Android device reached Keygen. Validation returned HTTP 200 with +`FINGERPRINT_SCOPE_MISMATCH`. This code means the key exists but the new device fingerprint is not on +the license yet. It is an internal activation signal, not a network error and not a user-facing +failure. + +The fixed activation path passed live at 23:34 local: + +- the first machine-list request returned HTTP 200 with four devices; +- machine activation returned HTTP 201; +- the local entitlement transaction completed prepare, commit, and finalize; +- the final machine-list request returned HTTP 200 with five devices; +- the user confirmed that Off Grid AI Pro activation succeeded. + +This closes the fresh-device activation defect. Do not repeat the live check unless licensing code or +activation-owner wiring changes. The final Android production build remains a separate release gate. + +--- + +## Release 107 two-way exact-operation Sync needs final device proof + +**Status:** Shared Code and build evidence exist; final Mobile Built and Live verified gates are +open. Filed 2026-08-29. + +Mobile loads the full operation log through `loadOps()`. No Mobile production caller uses Shared +compaction helpers. Durable per-entity and per-device watermarks restore anti-entropy position, while +exact operation IDs own delivery acknowledgement and deduplication. Mobile must still accept a valid +delayed operation below a watermark. + +Keep this gap open until the final Android and iOS builds pass and one final physical journey proves +both directions: + +1. One Mobile change reaches Desktop once. +2. One Desktop change reaches Mobile once. +3. A restart does not cause a record flood or active compaction. +4. A destination-gated delayed operation still arrives when its destination becomes eligible. + +--- + +## Release 107 QR, reconnect, and residency changes need final Mobile proof + +**Status:** Shared QR and Android residency Code evidence pass. Mobile UI code is still being +finalized. Final iOS and Android Built and Live verified gates are open. Filed 2026-08-30. + +The Shared pairing contract is built and tested. The Sync ESM, CJS, and DTS builds pass. Focused QR, +real-handshake, and ESM/CJS runtime checks pass 21/21. The final QR suite passes 11/11. Sync typecheck, +focused ESLint, and diff check pass. + +The Android residency crash came from resolving a Kotlin `LinkedHashMap` through a React Native +Promise. `MeshResidencyModule.begin()` and `state()` now use one canonical `ResidencySnapshot` to +`WritableMap` projection. Native checks pass 7/7. Focused rendered checks pass 23/23 with +open-handle detection and a clean exit. + +Keep this gap open until the final Mobile code and these installed checks pass: + +1. On iPhone, Show QR Code opens a bottom sheet. The code is hidden before that action. +2. Scan QR Code is a direct action. A valid scan identifies the device, shows the connection target, + selects the best reachable private route, and completes the identity-confirmed handshake. +3. Stale, malformed, duplicate, and wrong-device payloads stop without pairing. A plain pairing code + remains available. +4. Rescan continues when one saved route is unavailable. That device row shows the failure and its + reconnect action. The screen does not show an orphaned page-level transport error. +5. The local device card uses the Discoverable to new devices and Find nearby devices controls. It + does not repeat them with a generic Not discoverable label. +6. The final iOS build passes and the iPhone flows pass through iPhone Mirroring. +7. After the Android phone is connected again, the final Android build passes. Start Personal Mesh, + confirm there is no residency crash, then repeat QR, reconnect, and discovery checks on Android. + +Android live verification is explicitly deferred while the phone is disconnected. Do not close this +gap from unit, rendered, simulator, or iPhone evidence alone. + +### Reconnect review failures are code-resolved; device proof stays open (2026-08-30) + +Five PR 637 review failures were valid at Mobile `1263ac08`: + +1. App startup could start reconnect recovery while remote provider initialization still changed the + same registry and store. +2. A server move or WiFi rejoin with the same phone IP did not trigger active-connection validation. +3. An IP lookup that completed after watcher teardown could schedule recovery from a stopped watcher. +4. Port-only moved-server matching could overwrite a reachable saved server when a second server used + the same port. +5. A reachable active server returned before the enabled auto-discovery rule could run. + +The directed code fix makes provider initialization settle before watcher startup, gives the watcher +a teardown generation, validates an active connection after a same-IP check, limits moved-server +mapping to one missing saved endpoint and one unmatched discovered endpoint on the port, and evaluates +auto-discovery before the reachable-server return. + +Focused local evidence on the release tree: + +- `networkReconnect.test.ts`: same-IP foreground validation, stale lookup teardown, and steady-state + same-IP poll suppression pass. +- `remoteServerReconnect.test.ts`: 2/2 passed through the real manager, real stores, and real LAN + discovery logic for ambiguous port ownership and enabled discovery with a reachable active server. +- The existing focused `remoteServerManager.test.ts` suite passes 41/41. +- `App.test.tsx`: 3/3 passed with the targeted ignore override, including provider initialization + ordering and teardown safety. This file is excluded by the default Jest configuration, so the + exact targeted command is part of the evidence. + +The first focused manager draft failed because its fixture used the wrong production setting key +(`remoteAutoDiscovery`). That mock-based draft was removed. Its replacement uses the real app store +action and canonical `autoDiscoverRemoteModels` key. This was a test-fixture failure, not a product +failure. The first targeted App teardown failed because the old debug-log fixture did not expose +`stopDebugLogFile()`. The fixture now matches the production lifecycle, and the targeted App run +passes 3/3 without the prior post-test teardown crash. + +Keep the parent Release 107 reconnect gap open. The code is not built or live verified on iOS or +Android. The final installed-device reconnect journeys remain required. diff --git a/docs/PERSONAL_MESH.md b/docs/PERSONAL_MESH.md new file mode 100644 index 000000000..5ffd7dcd2 --- /dev/null +++ b/docs/PERSONAL_MESH.md @@ -0,0 +1,59 @@ +# Personal Mesh + +Your phone and computer can share data directly. There is no relay between them. + +## Requirements + +- Off Grid Pro must be active on each device. +- Pair each device once on the same Wi-Fi network. +- Use Sync port `37878` on every device, unless you set one different port on every device. +- Restart each app after you change the Sync port. +- Android uses the local network. Apple devices can also use Nearby when Wi-Fi is not available. + +Your Sync traffic is encrypted between paired devices. A private address does not remove pairing or +encryption. + +## Control who can find you + +Open **Settings > Sync**. The two controls have different jobs. + +| Control | When it is on | When it is off | +| --- | --- | --- | +| **Discoverable to new devices** | Other devices can find this device for pairing. | This device is Hidden. It does not advertise itself. It can still find other devices, and an existing paired connection stays active. | +| **Find nearby devices** | This device looks for other Off Grid AI devices. | This device stops looking. It can still be discoverable to other devices, and active connections stay active. | + +If you save Hidden, the app starts Hidden after a full quit or phone restart. It does not advertise +first and hide later. + +## Use one Sync port + +The default Sync port is `37878`. + +1. Open **Settings > Sync > Connection settings**. +2. Enter a port from `1024` to `65535`. +3. Select **Save**. +4. Set the same port on every paired device. +5. Restart every app. + +If one device uses a different port, the devices cannot make the direct connection. + +## Connect by private address + +Use this when discovery cannot reach a paired device across a private network, VPN, or tailnet. + +1. Pair the device on the same Wi-Fi network first. +2. Open **Settings > Sync**. +3. Select the saved device. +4. Select **Connect by address**. +5. Enter the private IP address or machine name, such as `100.116.255.25` or + `apples-macbook-pro-2`. +6. Select **Save and connect**. + +Enter only the address or name. Do not enter `http://`, a path, or a port. Off Grid uses the Sync +port from Connection settings. The saved address belongs to that device only. + +## What success looks like + +The device row changes to **Connected** and shows the route that is in use. A hidden device does not +appear to a new unpaired device. It can still show devices that it finds when **Find nearby devices** +is on. diff --git a/docs/PERSONAL_MESH_TEST_MATRIX.csv b/docs/PERSONAL_MESH_TEST_MATRIX.csv index bb7d7c2ca..6ab263584 100644 --- a/docs/PERSONAL_MESH_TEST_MATRIX.csv +++ b/docs/PERSONAL_MESH_TEST_MATRIX.csv @@ -12,7 +12,7 @@ 11,2 Pairing,Pairing stages are visible,Watch the screen through a successful pair,"Stages appear: connecting, verifying code, checking admission, saving trust, paired",P1,,,,Both devices must agree on the stage 12,2 Pairing,No false paired state,Kill the app mid-pair (airplane mode at the code step),"Never shows paired; shows an actionable recovery state",P0,,,,Trust must not commit before admission 13,3 Discovery,Devices find each other on the same Wi-Fi,Put both on one network; open Sync on both,Each appears in the other's discovered list within ~10s,P0,,,, -14,3 Discovery,Android advertises itself,From the Mac, look for the Android device without touching Android,Android appears in the Mac's list,P0,,n/a,,Proves NSD registerService works, not just browsing +14,3 Discovery,Android advertises itself,"From the Mac, look for the Android device without touching Android",Android appears in the Mac's list,P0,,n/a,,"Proves NSD registerService works, not just browsing" 15,3 Discovery,Route is reported honestly,Inspect the connected row on each device,"Says LAN when on Wi-Fi; Android never claims a nearby/proximity route",P1,,,,Android has no Nearby analog 16,3 Discovery,Reconnect after a Wi-Fi drop,Toggle Wi-Fi off then on on one device,Reconnects without re-pairing,P0,,,, 17,3 Discovery,Reconnect after moving networks,Move one device to a different Wi-Fi then back,Reconnects; no duplicate device rows,P1,,,, @@ -36,8 +36,14 @@ 35,8 Clipboard,Apple Universal Clipboard is not claimed as ours,Turn Off Grid clipboard sync OFF; copy on the Mac; check the iPhone,"If the text appears it is labelled a local pasteboard observation, NOT an Off Grid transfer",P0,,,n/a,The exact dishonesty this guards 36,8 Clipboard,Android clipboard semantics are labelled on their own terms,Copy on the Mac with sync on; check Android,"Arrives as an Off Grid transfer; Android has no Universal Clipboard so nothing arrives with sync off",P0,,n/a,, 37,9 Provider,Provider outage keeps verified devices working,Block api.keygen.sh; relaunch,"Pro still works; roster shows it is cached, not authoritative",P0,,,, -38,9 Provider,Known eviction beats a cached credential,Evict the device, then block the provider and relaunch it,Device does NOT present itself as Pro active,P0,,,, +38,9 Provider,Known eviction beats a cached credential,"Evict the device, then block the provider and relaunch it",Device does NOT present itself as Pro active,P0,,,, 39,9 Provider,Freshness is visible,Compare a fresh load with an offline load,"Says updated just now vs showing saved roster - provider unavailable",P1,,,, 40,10 Honesty,No ambiguous single number,Read the Devices screen on each platform,"Separate counts: registered / paired / connected now - never one bare ""1/5""",P1,,,, -41,10 Honesty,Same words on all three platforms,Compare the state labels across macOS, iOS and Android,"Identical vocabulary: registered, paired, connected, offline, registration required",P1,,,,Layout may differ; meaning may not +41,10 Honesty,Same words on all three platforms,"Compare the state labels across macOS, iOS and Android","Identical vocabulary: registered, paired, connected, offline, registration required",P1,,,,Layout may differ; meaning may not 42,10 Honesty,Debug builds never say Pro Active,Open a debug build without a license,"Says Development Access, never Pro Active",P1,,,, +43,11 Visibility,Hidden survives a cold start,"Turn Discoverable to new devices off; force-quit the app; relaunch; search from an unpaired device","The phone starts Hidden and never appears to the unpaired device; Find nearby devices keeps its saved state",P0,,,n/a,"Run on a real iPhone; watch the second device during the full launch" +44,11 Visibility,Hidden does not stop finding or an active session,"Connect two paired devices; turn Discoverable to new devices off on one; rescan and send a small record","The hidden device is not offered for new pairing; it still finds the peer and the active encrypted session carries the record",P0,,,, +45,11 Visibility,Find nearby off does not hide this device,"Leave Discoverable to new devices on; turn Find nearby devices off; search from the second device","The first device stops adding discovered rows; the second device can still find it; active connections stay active",P0,,,, +46,11 Connection,Private address reconnects a saved device,"Pair on Wi-Fi; move to a private VPN or tailnet; enter the saved device private IP address or machine name; select Save and connect","The exact saved device reconnects on the configured Sync port; no new device identity or pairing appears",P0,,,,"Enter a host only, with no URL scheme, path, or port" +47,11 Connection,Custom Sync port is one mesh setting,"Set a non-default port on one device only and restart; then set the same port on every device and restart all apps","The mismatch fails clearly; the matching port reconnects the same paired devices; default port 37878 still works after restore",P0,,,,"Use one disposable port from 1024 to 65535" +48,11 Failure recovery,Failed advertising stop keeps the true state and retries,"Use a diagnostic native boundary that rejects the first advertising stop; turn Discoverable off; remove the failure; turn it off again","The first action reports failure and the switch and saved value stay on; the second action stops advertising and the switch and saved value turn off",P0,,,n/a,"Failure injection is required because the native stop API has no normal user control" diff --git a/docs/RELEASE_TEST_CHECKLIST.csv b/docs/RELEASE_TEST_CHECKLIST.csv index 7deff6ebd..06475edde 100644 --- a/docs/RELEASE_TEST_CHECKLIST.csv +++ b/docs/RELEASE_TEST_CHECKLIST.csv @@ -177,7 +177,8 @@ 176,11 Polish,Stay-in-the-loop card placement,Settings -> scroll to the community area,'Stay in the loop' card sits directly BELOW 'Off Grid AI PRO' and ABOVE 'Star on GitHub',P2,,,New this build (card reordered) 177,11 Polish,Follow on X opens the profile,Settings -> Stay in the loop -> tap 'Follow @alichherawalla on X',Opens the X profile in the browser/app,P2,,,New. FOLLOW_X_URL. DEVICE-ONLY: external link 178,11 Polish,Join Slack opens the invite,Settings -> Stay in the loop -> tap 'Join the Slack community',Opens the Slack invite link,P2,,,New. SLACK_INVITE_URL. DEVICE-ONLY: external link -179,11 Polish,Share on X prefilled,Settings -> Community -> tap 'Share on X',Opens X compose prefilled with the Off Grid share text,P2,,,shareOnX() +179,11 Polish,Rate on the store opens the right store,Settings -> Community -> tap the rate row,"Opens THIS platform's store: App Store review sheet on iOS, Play listing on Android. Never the other platform's store",P2,,,rateOnStore(); replaced the old 'Share on X' row +179a,11 Polish,Support sheet offers a rating not an X share,Generate twice in one session and wait for the 'Support Open-Source AI' sheet,"The sheet's second button reads 'Rate on the App Store' (iOS) or 'Rate on Google Play' (Android) and opens that store. Sheet closes after tapping and does not return this session",P2,,,SharePromptSheet; once per session, never after engaging 180,12 This-release,Gemma-4 native-first thinking + tool,Select Gemma-4 -> thinking ON + enable a tool -> send one turn that reasons then calls the tool,Thinking block + tool-result bubble + answer all render correctly and IN ORDER,P0,,,PR NAMESAKE + GAPS_BACKLOG:204. Pull offgrid-debug.log and confirm [GEMMA-FALLBACK] NEVER fires (native 'auto' parse worked). DEVICE-ONLY - release blocker 181,12 This-release,Upgrade-over-install keeps data + loading mode,ALT to fresh install: install the CURRENT released build -> set Aggressive loading + download a model + have a chat -> install THIS build OVER it (no delete),Models/chats/downloads intact; loading mode reads Aggressive (not blank/default),P0,,,loadPolicySync legacy aggressiveModelLoading->3-mode migration - the upgrader a fresh install can't see. DEVICE-ONLY - release blocker. Run as its OWN pass (mutually exclusive with row 1) 182,12 This-release,Parse-once thinking+tool+answer on litert,litert model (Android) -> thinking + a tool in one turn,Thinking block + tool result + answer in the SAME correct order as llama,P1,,,Single-grammar parse-once across engines. Android-only diff --git a/docs/tests/QA_TEST_PLAN.md b/docs/tests/QA_TEST_PLAN.md index b876fdcba..411236100 100644 --- a/docs/tests/QA_TEST_PLAN.md +++ b/docs/tests/QA_TEST_PLAN.md @@ -1309,7 +1309,7 @@ | # | Step | Expected | |---|------|----------| | 1 | "Star on GitHub" | Opens external browser to GitHub repo | -| 2 | "Share on X" | Opens X/Twitter with pre-filled tweet | +| 2 | "Rate on the App Store" / "Rate on Google Play" | Opens this platform's store rating page | ### 22.4 Reset Onboarding (debug) (P2) @@ -1407,7 +1407,7 @@ | # | Step | Expected | |---|------|----------| -| 1 | Trigger met | Sheet appears: "Star on GitHub", "Share on X", "Maybe later" | +| 1 | Trigger met | Sheet appears: "Star on GitHub", the platform rating action, "Maybe later" | | 2 | Tap GitHub or X | Opens link. `hasEngagedSharePrompt` flag set. Never shown again | | 3 | "Maybe later" | Dismisses without setting flag. Will show again | diff --git a/ios/BlobChannelModule.m b/ios/BlobChannelModule.m index 4dbba93b1..3d19a0b2e 100644 --- a/ios/BlobChannelModule.m +++ b/ios/BlobChannelModule.m @@ -6,6 +6,9 @@ @interface RCT_EXTERN_MODULE(BlobChannelModule, RCTEventEmitter) RCT_EXTERN_METHOD(lanAddress:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(interfaceCandidates:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(serve:(NSDictionary *)options resolve:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject) diff --git a/ios/BlobChannelModule.swift b/ios/BlobChannelModule.swift index b010b60fe..2f4cfb69e 100644 --- a/ios/BlobChannelModule.swift +++ b/ios/BlobChannelModule.swift @@ -38,6 +38,19 @@ final class BlobChannelModule: RCTEventEmitter { work.async { resolve(BlobChannelSupport.lanAddress()) } } + /// All current IPv4 interfaces; the shared QR projector decides which routes are safe. + @objc(interfaceCandidates:withRejecter:) + func interfaceCandidates( + resolve: @escaping RCTPromiseResolveBlock, + reject _: @escaping RCTPromiseRejectBlock + ) { + work.async { + resolve(BlobChannelSupport.interfaceCandidates().map { candidate in + ["host": candidate.host, "interfaceName": candidate.interfaceName] + }) + } + } + /// Offer an endpoint for one transfer, and answer the url a peer should stream to. /// /// Resolves with nothing when this device has no address on a shared network: there is no endpoint diff --git a/ios/BlobChannelSupport.swift b/ios/BlobChannelSupport.swift index d81f9ccc1..543dce01e 100644 --- a/ios/BlobChannelSupport.swift +++ b/ios/BlobChannelSupport.swift @@ -2,6 +2,16 @@ import Foundation /// The small shared pieces of the fast transfer path on this device. enum BlobChannelSupport { + struct InterfaceCandidate: Equatable { + let interfaceName: String + let host: String + let isUp: Bool + let isLoopback: Bool + let isLinkLocal: Bool + let isAnyLocal: Bool + let isMulticast: Bool + } + /// One transfer's request head: the line and the three headers that decide anything. struct Head { let requestId: String @@ -76,4 +86,57 @@ enum BlobChannelSupport { } return candidate } + + /// Current numeric IPv4 interfaces. Shared sync code owns route safety and classification. + static func interfaceCandidates() -> [InterfaceCandidate] { + var first: UnsafeMutablePointer? + guard getifaddrs(&first) == 0, let start = first else { return [] } + defer { freeifaddrs(first) } + var records: [InterfaceCandidate] = [] + for pointer in sequence(first: start, next: { $0.pointee.ifa_next }) { + let entry = pointer.pointee + guard let address = entry.ifa_addr, address.pointee.sa_family == UInt8(AF_INET) else { + continue + } + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + guard + getnameinfo( + address, socklen_t(address.pointee.sa_len), &host, socklen_t(host.count), nil, 0, + NI_NUMERICHOST) == 0 + else { continue } + let text = String(cString: host) + let flags = Int32(entry.ifa_flags) + records.append( + InterfaceCandidate( + interfaceName: String(cString: entry.ifa_name), + host: text, + isUp: flags & IFF_UP != 0, + isLoopback: flags & IFF_LOOPBACK != 0 || text.hasPrefix("127."), + isLinkLocal: text.hasPrefix("169.254."), + isAnyLocal: text == "0.0.0.0", + isMulticast: isIPv4Multicast(text))) + } + return usableInterfaceCandidates(records) + } + + static func usableInterfaceCandidates( + _ records: [InterfaceCandidate] + ) -> [InterfaceCandidate] { + var seen = Set() + return records + .filter { record in + record.isUp && + !record.isLoopback && + !record.isLinkLocal && + !record.isAnyLocal && + !record.isMulticast + } + .filter { record in seen.insert("\(record.interfaceName)\u{0}\(record.host)").inserted } + .sorted { ($0.interfaceName, $0.host) < ($1.interfaceName, $1.host) } + } + + private static func isIPv4Multicast(_ host: String) -> Bool { + guard let firstOctet = Int(host.split(separator: ".", maxSplits: 1)[0]) else { return true } + return (224...239).contains(firstOctet) + } } diff --git a/ios/BlobChannelUploader.swift b/ios/BlobChannelUploader.swift index 4b6760615..bdc8765b8 100644 --- a/ios/BlobChannelUploader.swift +++ b/ios/BlobChannelUploader.swift @@ -80,8 +80,15 @@ final class BlobChannelUploader { } } connection.start(queue: queue) - _ = ready.wait(timeout: .now() + 15) + try waitForSignal( + ready, + timeout: .seconds(15), + message: "the endpoint did not become reachable" + ) if let problem { throw problem } + guard connection.state == .ready else { + throw failure("the endpoint did not become ready") + } live.hold(request.requestId, connection) defer { _ = live.take(request.requestId) @@ -145,7 +152,11 @@ final class BlobChannelUploader { problem = error done.signal() }) - _ = done.wait(timeout: .now() + 60) + try waitForSignal( + done, + timeout: .seconds(60), + message: "the endpoint stopped accepting the payload" + ) if let problem { throw problem } } @@ -156,11 +167,31 @@ final class BlobChannelUploader { answer = String(data: data ?? Data(), encoding: .utf8) ?? "" done.signal() } - _ = done.wait(timeout: .now() + 60) + try waitForSignal( + done, + timeout: .seconds(60), + message: "the endpoint did not confirm the payload" + ) guard answer.hasPrefix("HTTP/1.1 200") else { throw NSError( domain: "ai.offgridmobile.blob", code: 1, userInfo: [NSLocalizedDescriptionKey: "the endpoint answered \(answer.prefix(32))"]) } } + + /// A network deadline is a failure, not a successful empty response. + /// + /// `DispatchSemaphore.wait` reports a timeout as a return value. Ignoring that value made an + /// unreachable endpoint look ready, so a transfer stayed at zero bytes until its manager deadline. + /// Throwing here lets the shared transfer manager use its slower fallback route while the peer is + /// still connected. + static func waitForSignal( + _ semaphore: DispatchSemaphore, + timeout: DispatchTimeInterval, + message: String + ) throws { + guard semaphore.wait(timeout: .now() + timeout) == .success else { + throw failure(message) + } + } } diff --git a/ios/OffgridMobile/AppDelegate.swift b/ios/OffgridMobile/AppDelegate.swift index 1ec33f1d8..329dbdb2c 100644 --- a/ios/OffgridMobile/AppDelegate.swift +++ b/ios/OffgridMobile/AppDelegate.swift @@ -51,7 +51,10 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { override func bundleURL() -> URL? { #if DEBUG - RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") + // `ios:device` embeds main.jsbundle so a physical phone can start without Metro. Simulator + // debug builds do not embed it and keep the normal Metro development path. + Bundle.main.url(forResource: "main", withExtension: "jsbundle") + ?? RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") #else Bundle.main.url(forResource: "main", withExtension: "jsbundle") #endif diff --git a/ios/OffgridMobile/Info.plist b/ios/OffgridMobile/Info.plist index c06edb52a..350fa2054 100644 --- a/ios/OffgridMobile/Info.plist +++ b/ios/OffgridMobile/Info.plist @@ -46,6 +46,14 @@ NSAllowsLocalNetworking + NSExceptionDomains + + 100.64.0.0/10 + + NSExceptionAllowsInsecureHTTPLoads + + + NSBonjourServices diff --git a/ios/OffgridMobileTests/OffgridMobileTests.swift b/ios/OffgridMobileTests/OffgridMobileTests.swift index 5bd1f3a82..5f83bc443 100644 --- a/ios/OffgridMobileTests/OffgridMobileTests.swift +++ b/ios/OffgridMobileTests/OffgridMobileTests.swift @@ -21,6 +21,52 @@ private func makeTempDirectory() -> URL { return url } +final class BlobChannelInterfaceCandidatesTests: XCTestCase { + func testUsableKeepsOnlyActiveUnicastInterfacesAndPreservesNames() { + let candidates = [ + record("en0", "192.168.1.10"), + record("utun3", "100.80.1.2"), + record("lo0", "127.0.0.1", isLoopback: true), + record("en0", "169.254.2.3", isLinkLocal: true), + record("down0", "10.0.0.4", isUp: false), + record("any0", "0.0.0.0", isAnyLocal: true), + record("cast0", "224.0.0.1", isMulticast: true), + ] + + XCTAssertEqual( + BlobChannelSupport.usableInterfaceCandidates(candidates), + [record("en0", "192.168.1.10"), record("utun3", "100.80.1.2")]) + } + + func testUsableDeduplicatesPerInterfaceWithoutCollapsingDifferentInterfaces() { + let candidate = record("utun3", "100.64.0.9") + + XCTAssertEqual( + BlobChannelSupport.usableInterfaceCandidates( + [candidate, candidate, record("utun4", "100.64.0.9")]), + [candidate, record("utun4", "100.64.0.9")]) + } + + private func record( + _ interfaceName: String, + _ host: String, + isUp: Bool = true, + isLoopback: Bool = false, + isLinkLocal: Bool = false, + isAnyLocal: Bool = false, + isMulticast: Bool = false + ) -> BlobChannelSupport.InterfaceCandidate { + BlobChannelSupport.InterfaceCandidate( + interfaceName: interfaceName, + host: host, + isUp: isUp, + isLoopback: isLoopback, + isLinkLocal: isLinkLocal, + isAnyLocal: isAnyLocal, + isMulticast: isMulticast) + } +} + final class BlobReceiveWindowTests: XCTestCase { func testBodyWaitsForOneCompleteAuthenticatedFrame() { let productionFrame = 4 * 1_048_576 + BlobFrameCipher.tagBytes @@ -44,6 +90,77 @@ final class BlobReceiveWindowTests: XCTestCase { } } +final class BlobChannelUploaderDeadlineTests: XCTestCase { + func testAReachedNetworkSignalContinues() { + let signal = DispatchSemaphore(value: 0) + signal.signal() + + XCTAssertNoThrow( + try BlobChannelUploader.waitForSignal( + signal, timeout: .milliseconds(1), message: "should not time out")) + } + + func testAnUnreachedNetworkSignalFailsInsteadOfPretendingToContinue() { + let signal = DispatchSemaphore(value: 0) + + XCTAssertThrowsError( + try BlobChannelUploader.waitForSignal( + signal, timeout: .milliseconds(0), message: "the endpoint did not become reachable") + ) { error in + XCTAssertEqual(error.localizedDescription, "the endpoint did not become reachable") + } + } +} + +final class ProximityAdvertisingControllerTests: XCTestCase { + func testStopAndRestartReachTheNativeAdvertiserWithoutRestartingTheSession() { + let controller = ProximityAdvertisingController() + var starts = 0 + var stops = 0 + controller.install( + start: { starts += 1 }, + stop: { stops += 1 } + ) + XCTAssertFalse(controller.isAdvertising) + XCTAssertEqual(starts, 0) + + XCTAssertTrue(controller.start()) + controller.stop() + XCTAssertFalse(controller.isAdvertising) + XCTAssertEqual(starts, 1) + XCTAssertEqual(stops, 1) + + XCTAssertTrue(controller.start()) + XCTAssertTrue(controller.isAdvertising) + XCTAssertEqual(starts, 2) + } + + func testReplacingTheAdvertiserPreservesWhetherItWasHidden() { + let controller = ProximityAdvertisingController() + var firstStarts = 0 + var firstStops = 0 + var replacementStarts = 0 + controller.install( + start: { firstStarts += 1 }, + stop: { firstStops += 1 } + ) + XCTAssertTrue(controller.start()) + + controller.install( + start: { replacementStarts += 1 }, + stop: {} + ) + XCTAssertEqual(firstStops, 1) + XCTAssertEqual(replacementStarts, 1) + + controller.stop() + controller.install(start: { replacementStarts += 1 }, stop: {}) + XCTAssertFalse(controller.isAdvertising) + XCTAssertEqual(replacementStarts, 1) + XCTAssertEqual(firstStarts, 1) + } +} + final class StreamingFileHasherTests: XCTestCase { func testProducesTheStandardSHA512DigestAcrossManyChunks() throws { let url = FileManager.default.temporaryDirectory diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3ed9ea153..766110f6f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -2040,7 +2040,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-executorch (0.8.1): + - react-native-executorch (0.9.3): - boost - DoubleConversion - fast_float @@ -3459,6 +3459,12 @@ PODS: - SSZipArchive (~> 2.5.5) - SocketRocket (0.7.1) - SSZipArchive (2.5.5) + - VisionCamera (4.7.3): + - VisionCamera/Core (= 4.7.3) + - VisionCamera/React (= 4.7.3) + - VisionCamera/Core (4.7.3) + - VisionCamera/React (4.7.3): + - React-Core - whisper-rn (0.5.5): - boost - DoubleConversion @@ -3599,6 +3605,7 @@ DEPENDENCIES: - RNWorklets (from `../node_modules/react-native-worklets`) - RNZipArchive (from `../node_modules/react-native-zip-archive`) - SocketRocket (~> 0.7.1) + - VisionCamera (from `../node_modules/react-native-vision-camera`) - whisper-rn (from `../node_modules/whisper.rn`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -3823,6 +3830,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-worklets" RNZipArchive: :path: "../node_modules/react-native-zip-archive" + VisionCamera: + :path: "../node_modules/react-native-vision-camera" whisper-rn: :path: "../node_modules/whisper.rn" Yoga: @@ -3881,7 +3890,7 @@ SPEC CHECKSUMS: react-native-background-downloader: b02d12c3961322ce1c85fa0f8b3e4adb5b652106 react-native-document-picker: dc2d83366e47e89e7c51e8a41eab99c1d54e941c react-native-document-viewer: 8c6ed07e7e27352743fa98e8dd6d288ad925b884 - react-native-executorch: 9a44ee2b18773cbe5ad2e6d7376eb76f347e2935 + react-native-executorch: 863673cf458ceec5df61012fa30981ffa3ee8fc4 react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba react-native-image-picker: 0314366753615115fa55c3cc937ac44cb7e75702 react-native-keyboard-controller: 7534b5a39d1e8b2b79f86e8e998ed71c7154f69f @@ -3940,6 +3949,7 @@ SPEC CHECKSUMS: RNZipArchive: f2806ba80e24cf1984d6a7cb361d8a07d734997d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 + VisionCamera: 7187b3dac1ff3071234ead959ce311875748e14f whisper-rn: 7566faf9b7d78e39ab9fc634cb90fdee81177793 Yoga: 5456bb010373068fc92221140921b09d126b116e diff --git a/ios/SyncProximityModule.m b/ios/SyncProximityModule.m index 58edac185..0925d65b3 100644 --- a/ios/SyncProximityModule.m +++ b/ios/SyncProximityModule.m @@ -10,6 +10,12 @@ @interface RCT_EXTERN_MODULE(SyncProximityModule, RCTEventEmitter) rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(rescan:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(stopBrowsing:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(startAdvertising:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(stopAdvertising:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(updateDevice:(NSDictionary *)device resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) diff --git a/ios/SyncProximityModule.swift b/ios/SyncProximityModule.swift index 93a48aa77..d548c3dde 100644 --- a/ios/SyncProximityModule.swift +++ b/ios/SyncProximityModule.swift @@ -5,6 +5,45 @@ import React private let proximityServiceType = "offgrid-sync" private let proximityConnectTimeout: TimeInterval = 12 +/// Owns the advertiser's active state independently from browsing and sessions. +/// The closures keep MultipeerConnectivity at the native boundary while making +/// stop, restart, and advertiser replacement deterministic in native tests. +final class ProximityAdvertisingController { + private var startPeer: (() -> Void)? + private var stopPeer: (() -> Void)? + private(set) var isAdvertising = false + + func install(start: @escaping () -> Void, stop: @escaping () -> Void) { + let shouldRestart = isAdvertising + if shouldRestart { stopPeer?() } + startPeer = start + stopPeer = stop + if shouldRestart { startPeer?() } + } + + @discardableResult + func start() -> Bool { + guard let startPeer else { return false } + if !isAdvertising { + startPeer() + isAdvertising = true + } + return true + } + + func stop() { + guard isAdvertising else { return } + stopPeer?() + isAdvertising = false + } + + func clear() { + stop() + startPeer = nil + stopPeer = nil + } +} + private struct ProximityDevice { let id: String let name: String @@ -93,6 +132,7 @@ final class SyncProximityModule: RCTEventEmitter { private var localDevice: ProximityDevice? private var localPeer: MCPeerID? private var advertiser: MCNearbyServiceAdvertiser? + private let advertising = ProximityAdvertisingController() private var browser: MCNearbyServiceBrowser? private var peersByDeviceId: [String: MCPeerID] = [:] private var devicesByPeerName: [String: ProximityDevice] = [:] @@ -145,7 +185,10 @@ final class SyncProximityModule: RCTEventEmitter { self.browser = browser advertiser.delegate = self browser.delegate = self - advertiser.startAdvertisingPeer() + advertising.install( + start: { advertiser.startAdvertisingPeer() }, + stop: { advertiser.stopAdvertisingPeer() } + ) browser.startBrowsingForPeers() resolve(nil) } @@ -189,6 +232,46 @@ final class SyncProximityModule: RCTEventEmitter { } } + @objc + func stopBrowsing( + _ resolve: @escaping RCTPromiseResolveBlock, + rejecter _: @escaping RCTPromiseRejectBlock + ) { + stateQueue.async { [weak self] in + self?.browser?.stopBrowsingForPeers() + resolve(nil) + } + } + + @objc + func startAdvertising( + _ resolve: @escaping RCTPromiseResolveBlock, + rejecter reject: @escaping RCTPromiseRejectBlock + ) { + stateQueue.async { [weak self] in + guard let self, advertising.start() else { + reject( + "proximity_not_started", + "Sync proximity is not running.", + nil + ) + return + } + resolve(nil) + } + } + + @objc + func stopAdvertising( + _ resolve: @escaping RCTPromiseResolveBlock, + rejecter _: @escaping RCTPromiseRejectBlock + ) { + stateQueue.async { [weak self] in + self?.advertising.stop() + resolve(nil) + } + } + @objc func updateDevice( _ device: [String: Any], @@ -208,7 +291,6 @@ final class SyncProximityModule: RCTEventEmitter { ) return } - advertiser?.stopAdvertisingPeer() advertiser?.delegate = nil let replacement = MCNearbyServiceAdvertiser( peer: peer, @@ -218,7 +300,10 @@ final class SyncProximityModule: RCTEventEmitter { localDevice = parsed advertiser = replacement replacement.delegate = self - replacement.startAdvertisingPeer() + advertising.install( + start: { replacement.startAdvertisingPeer() }, + stop: { replacement.stopAdvertisingPeer() } + ) resolve(nil) } } @@ -352,7 +437,7 @@ final class SyncProximityModule: RCTEventEmitter { } private func stopInternal(notifyConnections: Bool) { - advertiser?.stopAdvertisingPeer() + advertising.clear() browser?.stopBrowsingForPeers() advertiser?.delegate = nil browser?.delegate = nil diff --git a/jest.setup.ts b/jest.setup.ts index 09127be0b..6bdf70d34 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -41,6 +41,18 @@ jest.mock('react-native-edge-to-edge', () => ({ NavigationBar: () => null, })); +// react-native-vision-camera is a native module (NitroModules); stub it so the +// QR scanner (mesh Scan-to-pair) can render in jest without the native view. +jest.mock('react-native-vision-camera', () => ({ + Camera: () => null, + useCameraDevice: jest.fn(() => undefined), + useCameraPermission: jest.fn(() => ({ + hasPermission: false, + requestPermission: jest.fn(), + })), + useCodeScanner: jest.fn((config: unknown) => config), +})); + // ============================================================================ // AsyncStorage Mock // ============================================================================ @@ -259,14 +271,19 @@ jest.mock('@react-native-community/slider', () => { // A voice carries its own assets (embedding + tagger + lexicon) in addition to // the two shared core .pte models — mirror that so completeness checks // (_activeVoiceSources) have a realistic full asset set to validate against. -const mockVoiceConfig = { - id: 'mock_voice', - voiceSource: 'https://example.test/kokoro/voices/af_heart.bin', - extra: { - taggerSource: 'https://example.test/kokoro/tagger.pt', - lexiconSource: 'https://example.test/kokoro/lexicon.json', +const mockKokoroConfig = (voice: string, language: string) => ({ + model: { + durationPredictorSource: `https://example.test/kokoro/${language}/duration_predictor.pte`, + synthesizerSource: `https://example.test/kokoro/${language}/synthesizer.pte`, }, -}; + voiceSource: `https://example.test/kokoro/voices/${voice}.bin`, + phonemizerConfig: { + lang: language, + taggerSource: `https://example.test/kokoro/${language}/tagger.pt`, + lexiconSource: `https://example.test/kokoro/${language}/lexicon.json`, + neuralModelSource: `https://example.test/kokoro/${language}/phonemizer.pte`, + }, +}); jest.mock('react-native-executorch', () => ({ // Faithful init leaf for the executorch native runtime (a genuine external native boundary): // initExecutorch registers the resource fetcher so the runtime is ready to load models through @@ -284,19 +301,64 @@ jest.mock('react-native-executorch', () => ({ stream: jest.fn(() => Promise.resolve()), streamStop: jest.fn(), })), - KOKORO_MEDIUM: { - modelName: 'kokoro-medium', - durationPredictorSource: 'https://example.test/kokoro/medium/duration_predictor.pte', - synthesizerSource: 'https://example.test/kokoro/medium/synthesizer.pte', + models: { + text_to_speech: { + kokoro: { + en_us: { + heart: () => mockKokoroConfig('af_heart', 'en-us'), + river: () => mockKokoroConfig('af_river', 'en-us'), + sarah: () => mockKokoroConfig('af_sarah', 'en-us'), + adam: () => mockKokoroConfig('am_adam', 'en-us'), + michael: () => mockKokoroConfig('am_michael', 'en-us'), + santa: () => mockKokoroConfig('am_santa', 'en-us'), + }, + en_gb: { + emma: () => mockKokoroConfig('bf_emma', 'en-gb'), + daniel: () => mockKokoroConfig('bm_daniel', 'en-gb'), + }, + fr: { siwis: () => mockKokoroConfig('ff_siwis', 'fr') }, + es: { + dora: () => mockKokoroConfig('ef_dora', 'es'), + alex: () => mockKokoroConfig('em_alex', 'es'), + }, + it: { + sara: () => mockKokoroConfig('if_sara', 'it'), + nicola: () => mockKokoroConfig('im_nicola', 'it'), + }, + pt: { + dora: () => mockKokoroConfig('pf_dora', 'pt'), + santa: () => mockKokoroConfig('pm_santa', 'pt'), + }, + hi: { + alpha: () => mockKokoroConfig('hf_alpha', 'hi'), + omega: () => mockKokoroConfig('hm_omega', 'hi'), + psi: () => mockKokoroConfig('hm_psi', 'hi'), + }, + pl: { mateusz: () => mockKokoroConfig('pm_mateusz', 'pl') }, + de: { anna: () => mockKokoroConfig('df_anna', 'de') }, + }, + }, }, - KOKORO_VOICE_AF_HEART: mockVoiceConfig, - KOKORO_VOICE_AF_RIVER: mockVoiceConfig, - KOKORO_VOICE_AF_SARAH: mockVoiceConfig, - KOKORO_VOICE_AM_ADAM: mockVoiceConfig, - KOKORO_VOICE_AM_MICHAEL: mockVoiceConfig, - KOKORO_VOICE_AM_SANTA: mockVoiceConfig, - KOKORO_VOICE_BF_EMMA: mockVoiceConfig, - KOKORO_VOICE_BM_DANIEL: mockVoiceConfig, + KOKORO_AMERICAN_ENGLISH_FEMALE_HEART: mockKokoroConfig('af_heart', 'en-us'), + KOKORO_AMERICAN_ENGLISH_FEMALE_RIVER: mockKokoroConfig('af_river', 'en-us'), + KOKORO_AMERICAN_ENGLISH_FEMALE_SARAH: mockKokoroConfig('af_sarah', 'en-us'), + KOKORO_AMERICAN_ENGLISH_MALE_ADAM: mockKokoroConfig('am_adam', 'en-us'), + KOKORO_AMERICAN_ENGLISH_MALE_MICHAEL: mockKokoroConfig('am_michael', 'en-us'), + KOKORO_AMERICAN_ENGLISH_MALE_SANTA: mockKokoroConfig('am_santa', 'en-us'), + KOKORO_BRITISH_ENGLISH_FEMALE_EMMA: mockKokoroConfig('bf_emma', 'en-gb'), + KOKORO_BRITISH_ENGLISH_MALE_DANIEL: mockKokoroConfig('bm_daniel', 'en-gb'), + KOKORO_FRENCH_FEMALE_SIWIS: mockKokoroConfig('ff_siwis', 'fr'), + KOKORO_SPANISH_FEMALE_DORA: mockKokoroConfig('ef_dora', 'es'), + KOKORO_SPANISH_MALE_ALEX: mockKokoroConfig('em_alex', 'es'), + KOKORO_ITALIAN_FEMALE_SARA: mockKokoroConfig('if_sara', 'it'), + KOKORO_ITALIAN_MALE_NICOLA: mockKokoroConfig('im_nicola', 'it'), + KOKORO_PORTUGUESE_FEMALE_DORA: mockKokoroConfig('pf_dora', 'pt'), + KOKORO_PORTUGUESE_MALE_SANTA: mockKokoroConfig('pm_santa', 'pt'), + KOKORO_HINDI_FEMALE_ALPHA: mockKokoroConfig('hf_alpha', 'hi'), + KOKORO_HINDI_MALE_OMEGA: mockKokoroConfig('hm_omega', 'hi'), + KOKORO_HINDI_MALE_PSI: mockKokoroConfig('hm_psi', 'hi'), + KOKORO_POLISH_MALE_MATEUSZ: mockKokoroConfig('pm_mateusz', 'pl'), + KOKORO_GERMAN_FEMALE_ANNA: mockKokoroConfig('df_anna', 'de'), })); // react-native-executorch-bare-resource-fetcher mock. @@ -389,6 +451,7 @@ jest.mock('react-native-device-info', () => ({ isEmulator: jest.fn(() => Promise.resolve(false)), getDeviceId: jest.fn(() => 'test-device-id'), getHardware: jest.fn(() => Promise.resolve('unknown')), + getIpAddress: jest.fn(() => Promise.resolve('192.168.1.20')), })); // react-native-image-picker mock @@ -678,10 +741,13 @@ beforeEach(() => { // flakiness, far worse in-band). This afterEach requires RTL AFTER the test's resetModules, so it resolves // the SAME post-reset instance the test rendered on, and unmounts its tree. It also drops the global // `window` shim the harness installs for React 19's error reporter, so no true-global leaks across files. -afterEach(() => { +afterEach(async () => { // Only unmount when a test actually rendered via requireRTL (which stashed its own cleanup here). Do NOT // require RTL fresh — after a test's resetModules that pulls a new module graph and breaks the next test. - const g = globalThis as unknown as { __RTL_CLEANUP__?: () => void; __GEN_CLEANUP__?: () => void }; + const g = globalThis as unknown as { + __RTL_CLEANUP__?: () => void; + __GEN_CLEANUP__?: () => Promise; + }; if (g.__RTL_CLEANUP__) { try { g.__RTL_CLEANUP__(); } catch { /* already torn down */ } g.__RTL_CLEANUP__ = undefined; } // A generation left IN FLIGHT outlives its test. generationServiceHelpers schedules a 50ms token-buffer // flush; when a suite ends mid-reply that timer fires during the NEXT suite, which has since called @@ -689,7 +755,7 @@ afterEach(() => { // "Cannot read properties of undefined (reading 'getState')" — failing whichever suite happened to be // running. That is why exactly one rendered suite failed per run, with a different name each time, and why // it always passed in isolation. Whoever started a generation registers the stop here. - if (g.__GEN_CLEANUP__) { try { g.__GEN_CLEANUP__(); } catch { /* already torn down */ } g.__GEN_CLEANUP__ = undefined; } + if (g.__GEN_CLEANUP__) { try { await g.__GEN_CLEANUP__(); } catch { /* already torn down */ } g.__GEN_CLEANUP__ = undefined; } }); // Global timeout for async operations diff --git a/knip.json b/knip.json index 38f98b09c..9b4ab8aab 100644 --- a/knip.json +++ b/knip.json @@ -11,7 +11,6 @@ ], "ignoreBinaries": [ "swiftlint", - "xcpretty", "xcrun" ], "ignoreDependencies": [ diff --git a/metro.config.js b/metro.config.js index 0734f853e..f795dd421 100644 --- a/metro.config.js +++ b/metro.config.js @@ -13,7 +13,12 @@ const proExists = fs.existsSync(path.resolve(proPackagePath, 'package.json')); // than enabling `unstable_enablePackageExports` globally (that flag changes resolution for every // dep and breaks libraries with malformed exports maps). The package ships prebuilt CJS in dist/. const syncPackagePath = path.resolve(__dirname, '../shared/packages/sync'); +const automationPackagePath = path.resolve(__dirname, '../shared/packages/automation'); const ragPackagePath = path.resolve(__dirname, '../shared/packages/rag'); +// @offgrid/models: cross-platform model contracts (catalog, reasoning-budget rule) shared +// with desktop. Out-of-root like rag, prebuilt CJS in dist/. +const modelsPackagePath = path.resolve(__dirname, '../shared/packages/models'); +const uiPackagePath = path.resolve(__dirname, '../shared/packages/ui'); // @offgrid/speech: voice-turn decisions (when a spoken turn begins and ends) shared with desktop. // Out-of-root like sync, so Metro must watch it and be pointed at its built entry. const speechPackagePath = path.resolve(__dirname, '../shared/packages/speech'); @@ -27,7 +32,15 @@ const syncRuntimeModules = { const config = { // pro/ is a submodule inside the project root, so Metro already watches it by default. The sync // package is out-of-root, so Metro must be told to watch it (for its dist) — nothing else needed. - watchFolders: [syncPackagePath, ragPackagePath, speechPackagePath, sharedNodeModulesPath], + watchFolders: [ + syncPackagePath, + automationPackagePath, + ragPackagePath, + modelsPackagePath, + speechPackagePath, + uiPackagePath, + sharedNodeModulesPath, + ], resolver: { // When resolving modules from outside the project root (i.e. @offgrid/pro), // Metro falls back here so @babel/runtime and all other peer deps are found. @@ -47,7 +60,12 @@ const config = { // resolving the external package directory can fail in an already-running dev server // after the file dependency is added, even though Node can resolve the package. '@offgrid/rag': path.resolve(ragPackagePath, 'dist/index.js'), + '@offgrid/models': path.resolve(modelsPackagePath, 'dist/index.js'), '@offgrid/speech': path.resolve(speechPackagePath, 'dist/index.cjs'), + '@offgrid/ui': path.resolve(uiPackagePath, 'dist/index.js'), + // @offgrid/sync owns this dependency in its package manifest. Metro still needs the + // out-of-root file dependency mapped to a watched, built CommonJS entry. + '@offgrid/automation': path.resolve(automationPackagePath, 'dist/index.js'), // Points to the real pro package when present on disk (store builds), // falls back to a null stub so free builds bundle cleanly. '@offgrid/pro': proExists ? proPackagePath : proStubPath, diff --git a/package-lock.json b/package-lock.json index b2e07c0c4..0f826d55e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,11 @@ "@dr.pogodin/react-native-fs": "^2.38.1", "@kesha-antonov/react-native-background-downloader": "^4.5.6", "@modelcontextprotocol/sdk": "^1.29.0", + "@offgrid/models": "file:../shared/packages/models", "@offgrid/rag": "file:../shared/packages/rag", "@offgrid/speech": "file:../shared/packages/speech", "@offgrid/sync": "file:../shared/packages/sync", + "@offgrid/ui": "file:../shared/packages/ui", "@op-engineering/op-sqlite": "^15.2.5", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/slider": "^5.1.2", @@ -40,8 +42,8 @@ "react-native-calendar-events": "^2.2.0", "react-native-device-info": "^15.0.1", "react-native-edge-to-edge": "^1.8.1", - "react-native-executorch": "^0.8.1", - "react-native-executorch-bare-resource-fetcher": "^0.8.0", + "react-native-executorch": "^0.9.3", + "react-native-executorch-bare-resource-fetcher": "^0.9.1", "react-native-gesture-handler": "^2.30.0", "react-native-get-random-values": "^1.11.0", "react-native-haptic-feedback": "^2.3.3", @@ -49,6 +51,7 @@ "react-native-inappbrowser-reborn": "^3.7.1", "react-native-keyboard-controller": "^1.21.12", "react-native-keychain": "^10.0.0", + "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "^5.6.2", "react-native-screens": "^4.20.0", @@ -56,6 +59,7 @@ "react-native-tcp-socket": "^6.4.1", "react-native-url-polyfill": "^3.0.0", "react-native-vector-icons": "^10.3.0", + "react-native-vision-camera": "^4.7.3", "react-native-worklets": "^0.7.3", "react-native-zeroconf": "^0.14.0", "react-native-zip-archive": "7.1.0", @@ -100,6 +104,19 @@ "node": ">=20" } }, + "../shared/packages/automation": { + "name": "@offgrid/automation", + "version": "0.0.1", + "license": "AGPL-3.0-only", + "devDependencies": { + "c8": "^12.0.0" + } + }, + "../shared/packages/models": { + "name": "@offgrid/models", + "version": "0.0.1", + "license": "AGPL-3.0-only" + }, "../shared/packages/rag": { "name": "@offgrid/rag", "version": "0.0.1", @@ -120,6 +137,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@noble/hashes": "1.8.0", + "@offgrid/automation": "*", "bonjour-service": "^1.2.1", "js-sha512": "^0.9.0", "tweetnacl": "^1.0.3", @@ -129,6 +147,11 @@ "c8": "^12.0.0" } }, + "../shared/packages/ui": { + "name": "@offgrid/ui", + "version": "0.0.1", + "license": "AGPL-3.0-only" + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -4456,6 +4479,14 @@ "node": ">= 8" } }, + "node_modules/@offgrid/automation": { + "resolved": "../shared/packages/automation", + "link": true + }, + "node_modules/@offgrid/models": { + "resolved": "../shared/packages/models", + "link": true + }, "node_modules/@offgrid/rag": { "resolved": "../shared/packages/rag", "link": true @@ -4468,6 +4499,10 @@ "resolved": "../shared/packages/sync", "link": true }, + "node_modules/@offgrid/ui": { + "resolved": "../shared/packages/ui", + "link": true + }, "node_modules/@op-engineering/op-sqlite": { "version": "15.2.5", "resolved": "https://registry.npmjs.org/@op-engineering/op-sqlite/-/op-sqlite-15.2.5.tgz", @@ -4609,9 +4644,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4629,9 +4661,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4649,9 +4678,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4669,9 +4695,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4689,9 +4712,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4709,9 +4729,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4729,9 +4746,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4749,9 +4763,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4964,9 +4975,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4981,9 +4989,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4998,9 +5003,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5015,9 +5017,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5032,9 +5031,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5049,9 +5045,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5066,9 +5059,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5083,9 +5073,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8283,7 +8270,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8491,6 +8477,12 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -12667,14 +12659,30 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", - "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { - "uc.micro": "^1.0.1" + "uc.micro": "^2.0.0" } }, + "node_modules/linkify-it/node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/llama.rn": { "version": "0.13.0-rc.0", "resolved": "https://registry.npmjs.org/llama.rn/-/llama.rn-0.13.0-rc.0.tgz", @@ -14365,12 +14373,12 @@ } }, "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", "license": "MIT", "engines": { - "node": ">=14.19.0" + "node": ">=10.13.0" } }, "node_modules/possible-typed-array-names": { @@ -14520,6 +14528,141 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -14807,9 +14950,9 @@ } }, "node_modules/react-native-executorch": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/react-native-executorch/-/react-native-executorch-0.8.1.tgz", - "integrity": "sha512-DEVWs+Ki7p1C8mEgsHiabZizO/kDM0zELlJ+JFCfNCb2RrraMUXBTZIARWHPUbxpG17nqFswIZmwjUoNK5V36g==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/react-native-executorch/-/react-native-executorch-0.9.3.tgz", + "integrity": "sha512-eanpDe8sFFxbKfCh0lqrSVyU4/deiF1pZWtZOjZSV+w+qfuNsdrkQkoscxvhMwJOnvXNkBt4BXAr8pAlGOXStA==", "license": "MIT", "workspaces": [ "example" @@ -14818,7 +14961,6 @@ "@huggingface/jinja": "^0.5.0", "jsonrepair": "^3.12.0", "jsonschema": "^1.5.0", - "pngjs": "^7.0.0", "zod": "^4.3.6" }, "peerDependencies": { @@ -14827,9 +14969,9 @@ } }, "node_modules/react-native-executorch-bare-resource-fetcher": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/react-native-executorch-bare-resource-fetcher/-/react-native-executorch-bare-resource-fetcher-0.8.0.tgz", - "integrity": "sha512-PzSzK31qnKmwW06+JCbpQML24u3XiqYcWKQG0Y1cwPmkOqz0VppI0ZOeCZh03/03SMyuvwwEgteJtgO0uSP8sg==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/react-native-executorch-bare-resource-fetcher/-/react-native-executorch-bare-resource-fetcher-0.9.1.tgz", + "integrity": "sha512-ru5YN/4CQj3YlqW1L908PD7TEeZc5/63IV/xmjjDDz6+rpaR/aELA1Wo9ccRolgxWXTsCgMJ697fPQcC+TdZcw==", "license": "MIT", "peerDependencies": { "@dr.pogodin/react-native-fs": "^2.0.0", @@ -14947,6 +15089,22 @@ "node": ">=16" } }, + "node_modules/react-native-qrcode-svg": { + "version": "6.3.21", + "resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.21.tgz", + "integrity": "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.0", + "qrcode": "^1.5.4", + "text-encoding": "^0.7.0" + }, + "peerDependencies": { + "react": "*", + "react-native": ">=0.63.4", + "react-native-svg": ">=14.0.0" + } + }, "node_modules/react-native-reanimated": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", @@ -15121,6 +15279,30 @@ "node": ">=10" } }, + "node_modules/react-native-vision-camera": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz", + "integrity": "sha512-g1/neOyjSqn1kaAa2FxI/qp5KzNvPcF0bnQw6NntfbxH6tm0+8WFZszlgb5OV+iYlB6lFUztCbDtyz5IpL47OA==", + "license": "MIT", + "peerDependencies": { + "@shopify/react-native-skia": "*", + "react": "*", + "react-native": "*", + "react-native-reanimated": "*", + "react-native-worklets-core": "*" + }, + "peerDependenciesMeta": { + "@shopify/react-native-skia": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-worklets-core": { + "optional": true + } + } + }, "node_modules/react-native-worklets": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.3.tgz", @@ -15492,7 +15674,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "devOptional": true, "license": "ISC" }, "node_modules/resolve": { @@ -15871,7 +16052,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "devOptional": true, "license": "ISC" }, "node_modules/set-function-length": { @@ -16670,6 +16850,13 @@ "node": "*" } }, + "node_modules/text-encoding": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz", + "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==", + "deprecated": "no longer maintained", + "license": "(Unlicense OR Apache-2.0)" + }, "node_modules/text-encoding-polyfill": { "version": "0.6.7", "resolved": "https://registry.npmjs.org/text-encoding-polyfill/-/text-encoding-polyfill-0.6.7.tgz", @@ -17421,7 +17608,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "devOptional": true, "license": "ISC" }, "node_modules/which-typed-array": { diff --git a/package.json b/package.json index ca949de98..2539e3e75 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,11 @@ "@dr.pogodin/react-native-fs": "^2.38.1", "@kesha-antonov/react-native-background-downloader": "^4.5.6", "@modelcontextprotocol/sdk": "^1.29.0", + "@offgrid/models": "file:../shared/packages/models", "@offgrid/rag": "file:../shared/packages/rag", "@offgrid/speech": "file:../shared/packages/speech", "@offgrid/sync": "file:../shared/packages/sync", + "@offgrid/ui": "file:../shared/packages/ui", "@op-engineering/op-sqlite": "^15.2.5", "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/slider": "^5.1.2", @@ -62,8 +64,8 @@ "react-native-calendar-events": "^2.2.0", "react-native-device-info": "^15.0.1", "react-native-edge-to-edge": "^1.8.1", - "react-native-executorch": "^0.8.1", - "react-native-executorch-bare-resource-fetcher": "^0.8.0", + "react-native-executorch": "^0.9.3", + "react-native-executorch-bare-resource-fetcher": "^0.9.1", "react-native-gesture-handler": "^2.30.0", "react-native-get-random-values": "^1.11.0", "react-native-haptic-feedback": "^2.3.3", @@ -71,6 +73,7 @@ "react-native-inappbrowser-reborn": "^3.7.1", "react-native-keyboard-controller": "^1.21.12", "react-native-keychain": "^10.0.0", + "react-native-qrcode-svg": "^6.3.21", "react-native-reanimated": "^4.2.1", "react-native-safe-area-context": "^5.6.2", "react-native-screens": "^4.20.0", @@ -78,6 +81,7 @@ "react-native-tcp-socket": "^6.4.1", "react-native-url-polyfill": "^3.0.0", "react-native-vector-icons": "^10.3.0", + "react-native-vision-camera": "^4.7.3", "react-native-worklets": "^0.7.3", "react-native-zeroconf": "^0.14.0", "react-native-zip-archive": "7.1.0", @@ -126,6 +130,7 @@ }, "op-sqlite": {}, "overrides": { - "react": "19.2.0" + "react": "19.2.0", + "linkify-it": "^5.0.2" } } diff --git a/pro b/pro index 879094707..6dae50a50 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 879094707d0007e251cf0c9209ddbf8aaf35a83d +Subproject commit 6dae50a503820b73346c854b04fe1c07609faddc diff --git a/rules.md b/rules.md index 9b4ed9a26..9b30f56df 100644 --- a/rules.md +++ b/rules.md @@ -108,6 +108,22 @@ The emotional arc for all content: **Recognition -> Return -> Freedom**. Name wh > Run `node scripts/mirror-doctrine.mjs` in `shared/` after changing the canonical copy. > `--check` fails the build when a mirror drifts, so these cannot silently disagree. +## Debugging — reason from first principles + +**Ask what the thing IS, before you ask what is happening to it.** Name what the code should be in +one sentence ("a side panel is fixed to the right edge, full height"), read what it actually says, +and fix the gap. Almost every hard-looking bug here dissolves at that step. + +The failure mode is reaching for the environment instead: measuring window geometry, blaming an OS +setting, inspecting global CSS, theorising about the platform. Those are ways of not reading the +component. A real example: a gap between a side panel and the window edge got attributed to a macOS +tiled-window margin. The actual cause was in the component's own class list — it declared two +competing heights (`h-dvh` on top of `top-0 bottom-0`) inside a clipping wrapper. The fix was to say +the simple thing directly. + +So, before any tooling: if the answer requires unusual measurement to explain, the implementation is +probably wrong, and it is complicated where it should be plain. Simplify it and the symptom goes. + ## Debugging — start with the source of truth **Most bugs here are source-of-truth bugs, and the fix is almost always to collapse two sources into diff --git a/scripts/ios-device.sh b/scripts/ios-device.sh index 000b21b91..7ff2c7b98 100755 --- a/scripts/ios-device.sh +++ b/scripts/ios-device.sh @@ -167,6 +167,24 @@ else fi APP="build/device/Build/Products/Debug-iphoneos/OffgridMobile.app" + +# A physical iPhone cannot use the Mac's localhost. The React Native build phase +# writes the first Wi-Fi address it finds to ip.txt, but some networks isolate +# clients even when both devices are on the same subnet. Prefer an explicit host; +# otherwise use this Mac's Tailscale address when Metro is reachable there. The +# app keeps the embedded bundle as its fallback when Metro is not running. +METRO_HOST="${IOS_METRO_HOST:-}" +if [ -z "$METRO_HOST" ] && command -v tailscale >/dev/null 2>&1; then + TAILSCALE_HOST="$(tailscale ip -4 2>/dev/null | head -1 || true)" + if [ -n "$TAILSCALE_HOST" ] && [ "$(curl -fsS --max-time 2 "http://$TAILSCALE_HOST:8081/status" 2>/dev/null || true)" = "packager-status:running" ]; then + METRO_HOST="$TAILSCALE_HOST" + fi +fi +if [ -n "$METRO_HOST" ]; then + printf '%s\n' "$METRO_HOST" > "$APP/ip.txt" + echo "Debug Metro host: $METRO_HOST:8081" +fi + echo "Installing $APP ..." xcrun devicectl device install app --device "$DEVICE_ID" "$APP" diff --git a/src/bootstrap/hookRegistry.ts b/src/bootstrap/hookRegistry.ts index fdda2d2db..53386e6f4 100644 --- a/src/bootstrap/hookRegistry.ts +++ b/src/bootstrap/hookRegistry.ts @@ -12,8 +12,11 @@ type HookFn = (...args: any[]) => any; const hooks: Record = {}; -export function registerHook(name: string, fn: HookFn): void { +export function registerHook(name: string, fn: HookFn): () => void { hooks[name] = fn; + return () => { + if (hooks[name] === fn) delete hooks[name]; + }; } /** Call a hook if registered; returns its result, or undefined when absent. */ diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts index 6e25c05dd..5f6b8bc58 100644 --- a/src/bootstrap/loadProFeatures.ts +++ b/src/bootstrap/loadProFeatures.ts @@ -42,17 +42,21 @@ export async function loadProFeatures(isPro?: boolean): Promise { const credentialActive = isPro ?? licenseInfo.isPro; const credentialSaved = isPro === true || (licenseInfo.credentialSaved ?? licenseInfo.isPro); - const active = credentialActive || DEV_UNLOCK_PRO; + const expired = licenseInfo.expired === true; + const active = (credentialActive || DEV_UNLOCK_PRO) && !expired; // Single source of truth for "Pro is unlocked" — every upsell gate reads this, so a // keychain- or dev-unlocked Pro user never sees the upgrade prompt. useAppStore.getState().setHasRegisteredPro(credentialActive); useAppStore.getState().setHasSavedProCredential(credentialSaved); useAppStore.getState().setProActive(active); + useAppStore + .getState() + .setHasExpiredProCredential(expired); // A credential is not access. If the roster last told us this device is deactivated, the paid bundle // must not load at all - loading it and then hiding the entry points leaves every Pro service running. const admitted = selectHasProAccess(useAppStore.getState()) || DEV_UNLOCK_PRO; - if (typeof pro.activateSyncBootstrap === 'function') { + if (!expired && typeof pro.activateSyncBootstrap === 'function') { pro.activateSyncBootstrap({ registerScreen, registerSlot, diff --git a/src/bootstrap/slotRegistry.ts b/src/bootstrap/slotRegistry.ts index fa90db726..d78a39851 100644 --- a/src/bootstrap/slotRegistry.ts +++ b/src/bootstrap/slotRegistry.ts @@ -26,10 +26,15 @@ function emitChange(): void { export function registerSlot( name: string, component: ComponentType, -): void { - if (slots[name] === component) return; // no-op re-register (dev Fast Refresh) +): () => void { + if (slots[name] === component) return () => undefined; // no-op re-register (dev Fast Refresh) slots[name] = component; emitChange(); + return () => { + if (slots[name] !== component) return; + delete slots[name]; + emitChange(); + }; } export function getSlot(name: string): ComponentType | undefined { @@ -73,6 +78,9 @@ export const SLOTS = { messageAudioMode: 'message.audioMode', /** Per-message meta-row control (the TTS speak/play button) in chat mode. */ messageSpeakButton: 'message.speakButton', + /** Expanded content for a Pro task tool-result row. Core keeps the compact row and Pro owns the + * authoritative live, replay, and control detail. */ + taskToolDetail: 'message.taskToolDetail', /** Extra row in the chat-input quick-settings popover (voice mode toggle). */ quickSettingsAudioRow: 'quickSettings.audioRow', /** One-tap Chat↔Audio interface toggle in the chat-input pill icon row. @@ -85,4 +93,6 @@ export const SLOTS = { * download/management). The tab itself only appears when this is * registered, so free builds show just Text/Image. */ modelsScreenVoiceTab: 'modelsScreen.voiceTab', + /** Small Pro-owned Voice availability indicator on the core Auto Setup plan. */ + autoSetupVoiceIndicator: 'autoSetup.voiceIndicator', } as const; diff --git a/src/components/AppSheet.tsx b/src/components/AppSheet.tsx index 583440600..60f4e5c9c 100644 --- a/src/components/AppSheet.tsx +++ b/src/components/AppSheet.tsx @@ -1,4 +1,10 @@ -import React, { useRef, useEffect, useState, useCallback } from 'react'; +import React, { + useRef, + useEffect, + useLayoutEffect, + useState, + useCallback, +} from 'react'; import { View, Text, @@ -35,6 +41,8 @@ export interface AppSheetProps { showHeader?: boolean; showHandle?: boolean; elevation?: 'level3' | 'level4'; + /** Prevent every user-driven dismissal path while a critical action runs. */ + dismissible?: boolean; children: React.ReactNode; } @@ -51,21 +59,33 @@ function createSheetPanResponder({ backdropOpacity, setModalVisible, onCloseRef, + dismissibleRef, }: { translateY: Animated.Value; backdropOpacity: Animated.Value; setModalVisible: (v: boolean) => void; onCloseRef: React.MutableRefObject<() => void>; + dismissibleRef: React.MutableRefObject; }) { return PanResponder.create({ onStartShouldSetPanResponder: () => false, - onMoveShouldSetPanResponder: (_, { dy }) => Math.abs(dy) > 8, + onMoveShouldSetPanResponder: (_, { dy }) => + dismissibleRef.current && Math.abs(dy) > 8, onPanResponderMove: (_, { dy }) => { - if (dy > 0) { + if (dismissibleRef.current && dy > 0) { translateY.setValue(dy); } }, onPanResponderRelease: (_, { dy, vy }) => { + if (!dismissibleRef.current) { + Animated.spring(translateY, { + toValue: 0, + damping: 28, + stiffness: 300, + useNativeDriver: true, + }).start(); + return; + } if (dy > 80 || vy > 0.5) { Animated.parallel([ Animated.timing(translateY, { @@ -106,6 +126,7 @@ export const AppSheet: React.FC = ({ showHeader = true, showHandle = true, elevation = 'level3', + dismissible = true, children, }) => { const { elevation: elevationTokens } = useTheme(); @@ -117,14 +138,16 @@ export const AppSheet: React.FC = ({ const translateY = useRef(new Animated.Value(SCREEN_HEIGHT)).current; const backdropOpacity = useRef(new Animated.Value(0)).current; - // Keep onClose ref current for PanResponder + // Stable dismissal handlers read only committed props. Render-phase writes can + // leak values from a concurrent render that React later discards. const onCloseRef = useRef(onClose); - onCloseRef.current = onClose; - - // Keep onClosed ref current so the animateOut completion always calls the - // latest callback without recreating the close handlers. + const dismissibleRef = useRef(dismissible); const onClosedRef = useRef(onClosed); - onClosedRef.current = onClosed; + useLayoutEffect(() => { + dismissibleRef.current = dismissible; + onCloseRef.current = onClose; + onClosedRef.current = onClosed; + }, [dismissible, onClose, onClosed]); // Guards backdrop-tap dismiss during animate-in. // Using a ref (not state) so there are zero re-renders — a state-based @@ -139,9 +162,7 @@ export const AppSheet: React.FC = ({ // Calculate sheet max height from largest snap point const sheetMaxHeight = enableDynamicSizing ? SCREEN_HEIGHT * 0.85 - : resolveSnapPoint( - snapPoints?.[snapPoints.length - 1] || '50%', - ); + : resolveSnapPoint(snapPoints?.[snapPoints.length - 1] || '50%'); const levelTokens = elevationTokens[elevation]; @@ -220,7 +241,6 @@ export const AppSheet: React.FC = ({ }; } setModalVisible(true); - } else if (modalVisible) { animateOut(() => { setModalVisible(false); @@ -231,11 +251,18 @@ export const AppSheet: React.FC = ({ // Track keyboard height so the sheet lifts above the keyboard useEffect(() => { - const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; - const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; - const showSub = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height)); + const showEvent = + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + const showSub = Keyboard.addListener(showEvent, e => + setKeyboardHeight(e.endCoordinates.height), + ); const hideSub = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0)); - return () => { showSub.remove(); hideSub.remove(); }; + return () => { + showSub.remove(); + hideSub.remove(); + }; }, []); // Called by Modal when the Dialog is fully rendered and ready for touch @@ -250,6 +277,7 @@ export const AppSheet: React.FC = ({ // Backdrop taps are gated by backdropEnabled to prevent the long-press // finger-up event from closing the sheet before any action can be taken. const dismiss = useCallback(() => { + if (!dismissibleRef.current) return; animateOut(() => { setModalVisible(false); onCloseRef.current(); @@ -258,14 +286,20 @@ export const AppSheet: React.FC = ({ }, [animateOut]); const handleBackdropPress = useCallback(() => { - if (backdropEnabled.current) { + if (dismissibleRef.current && backdropEnabled.current) { dismiss(); } }, [dismiss]); // Swipe-to-dismiss on handle const panResponder = useRef( - createSheetPanResponder({ translateY, backdropOpacity, setModalVisible, onCloseRef }), + createSheetPanResponder({ + translateY, + backdropOpacity, + setModalVisible, + onCloseRef, + dismissibleRef, + }), ).current; if (!modalVisible && !visible) { @@ -334,7 +368,10 @@ export const AppSheet: React.FC = ({ { + if (!dismissibleRef.current) return; + (onHeaderClosePress || dismiss)(); + }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} > {closeLabel} @@ -347,7 +384,10 @@ export const AppSheet: React.FC = ({ {/* Bottom safe area spacer — hidden when keyboard is up (keyboard height includes it) */} {bottomInset > 0 && keyboardHeight === 0 && ( - + )} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index a78230572..9f39755aa 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -21,6 +21,7 @@ interface ButtonProps { style?: ViewStyle; textStyle?: TextStyle; testID?: string; + accessibilityLabel?: string; } export const Button: React.FC = ({ @@ -35,6 +36,7 @@ export const Button: React.FC = ({ style, textStyle, testID, + accessibilityLabel, }) => { const { colors } = useTheme(); const styles = useThemedStyles(createStyles); @@ -64,6 +66,8 @@ export const Button: React.FC = ({ disabled={disabled || loading} activeOpacity={0.7} testID={testID} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel ?? title} > {loading ? ( // The dots, not a ring spinner: a rotating ring on a button reads as a retry glyph, so @@ -75,7 +79,7 @@ export const Button: React.FC = ({ ) : ( <> {icon} - {title} + {title ? {title} : null} )} diff --git a/src/components/ChatInput/RecordingHint.tsx b/src/components/ChatInput/RecordingHint.tsx index 629111459..4eb4fe2c5 100644 --- a/src/components/ChatInput/RecordingHint.tsx +++ b/src/components/ChatInput/RecordingHint.tsx @@ -2,10 +2,20 @@ import React from 'react'; import { View, Text } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; import { useTheme, useThemedStyles } from '../../theme'; +import { LoadingDots } from '../LoadingDots'; import { createStyles } from './styles'; +import type { VoiceRecordInteractionMode } from '../VoiceRecordButton'; + +const processingLabel = ( + processing: 'loading' | 'starting' | 'transcribing', +): string => { + if (processing === 'loading') return 'Loading voice model...'; + if (processing === 'starting') return 'Starting microphone...'; + return 'Transcribing...'; +}; /** - * Push-to-talk hint shown INLINE in the composer while holding to record (the WhatsApp pattern): + * Voice interaction status shown INLINE in the composer while recording: * a recording dot on the left and "‹ Slide to cancel" centred. The mic sits to the right, outside * the pill, where the thumb is. Living in the composer (not as a floating pill over the mic) keeps * it always visible and never overlapping the mic (device 2026-07-15). @@ -13,9 +23,35 @@ import { createStyles } from './styles'; export const RecordingHint: React.FC<{ /** Hands-free: the mic is open but nobody has spoken, so nothing is being captured yet. */ awaitingSpeech?: boolean; -}> = ({ awaitingSpeech = false }) => { + interactionMode?: VoiceRecordInteractionMode; + processing?: 'loading' | 'starting' | 'transcribing'; +}> = ({ awaitingSpeech = false, interactionMode = 'idle', processing }) => { const { colors } = useTheme(); const styles = useThemedStyles(createStyles); + if (processing) { + return ( + + + + {processingLabel(processing)} + + + ); + } + if (interactionMode === 'locked') { + return ( + + + + Tap mic to stop + + + ); + } // Hands-free opens the recorder BEFORE the turn begins, so the red dot and "slide to cancel" were // shown at someone whose words were not being captured yet. Waiting says so instead. if (awaitingSpeech) { @@ -28,6 +64,16 @@ export const RecordingHint: React.FC<{ ); } + if (interactionMode === 'idle') { + return ( + + + + Recording... + + + ); + } return ( diff --git a/src/components/ChatInput/Voice.ts b/src/components/ChatInput/Voice.ts index 9d25e1ec0..a5407238e 100644 --- a/src/components/ChatInput/Voice.ts +++ b/src/components/ChatInput/Voice.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useWhisperTranscription } from '../../hooks/useWhisperTranscription'; -import { useWhisperStore, useUiModeStore, useAppStore } from '../../stores'; +import { useWhisperStore, useAppStore, useRemoteServerStore } from '../../stores'; import { activeModelService } from '../../services/activeModelService'; import { audioRecorderService } from '../../services/audioRecorderService'; import { whisperService } from '../../services/whisperService'; @@ -15,6 +15,7 @@ import logger from '../../utils/logger'; interface UseVoiceInputParams { conversationId?: string | null; + interfaceMode: 'chat' | 'audio'; onTranscript: (text: string) => void; onAudioAttachment?: (audio: { uri: string; format: 'wav' | 'mp3'; durationSeconds?: number; transcription?: string }) => void; /** Called in Audio Mode to auto-send. Includes audio info so caller can build attachment atomically. */ @@ -31,7 +32,50 @@ async function stopAndFinalise(silence: SilenceEndpoint): Promise ); } -export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, onAutoSend }: UseVoiceInputParams) { +/** Cancel the active recorder only. Session policy stays with useVoiceInput. */ +function cancelActiveCapture(input: { + isDirectRecording: boolean; + isAudioModeRecording: boolean; + setIsDirectRecording: (value: boolean) => void; + setIsAudioModeRecording: (value: boolean) => void; + stopWhisperRecording: () => void; + clearWhisperResult: () => void; + clearConversation: () => void; +}): void { + if (input.isDirectRecording || input.isAudioModeRecording) { + audioRecorderService.cancelRecording(); + if (input.isDirectRecording) input.setIsDirectRecording(false); + else input.setIsAudioModeRecording(false); + } else { + input.stopWhisperRecording(); + input.clearWhisperResult(); + } + input.clearConversation(); +} + +/** Build the one readiness boundary shared by realtime and file transcription. */ +function createWhisperReadiness( + downloadedModelId: string | null, + remoteTranscriptionAvailable: boolean, +): () => Promise { + if (remoteTranscriptionAvailable) return async () => true; + return () => ensureWhisperForTranscription({ + isSelectedModelLoaded: () => !!downloadedModelId && + whisperService.getLoadedModelPath() === whisperService.getModelPath(downloadedModelId), + hasDownloadedModel: () => !!downloadedModelId, + loadWhisper: () => useWhisperStore.getState().loadModel(), + freeGenerationModels: () => activeModelService.unloadAllModels(true).then(() => {}), + }); +} + +function useRemoteTranscriptionAvailable(): boolean { + return useRemoteServerStore(state => { + const server = state.servers.find(item => item.id === state.activeServerId); + return !!server?.mediaModels?.transcription; + }); +} + +export function useVoiceInput({ conversationId, interfaceMode, onTranscript, onAudioAttachment, onAutoSend }: UseVoiceInputParams) { const recordingConversationIdRef = useRef(null); const onTranscriptRef = useRef(onTranscript); onTranscriptRef.current = onTranscript; @@ -39,7 +83,8 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, onAudioAttachmentRef.current = onAudioAttachment; const onAutoSendRef = useRef(onAutoSend); onAutoSendRef.current = onAutoSend; - const { downloadedModelId } = useWhisperStore(); + const { downloadedModelId, transcriptionLanguage } = useWhisperStore(); + const remoteTranscriptionAvailable = useRemoteTranscriptionAvailable(); const [isDirectRecording, setIsDirectRecording] = useState(false); const [isAudioModeRecording, setIsAudioModeRecording] = useState(false); /** Hands-free: the mic is open but nobody has spoken yet, so the turn has not begun. */ @@ -50,30 +95,20 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, const supportsDirectAudio = (): boolean => activeModelService.supportsAudioInput() && audioRecorderService.supportsDirectAudioInput(); - const isInAudioInterfaceMode = (): boolean => - useUiModeStore.getState().interfaceMode === 'audio'; + // The rendered composer mode is the recording mode. Passing it in prevents the + // Voice layout and recorder from observing two different store snapshots. + const isInAudioInterfaceMode = (): boolean => interfaceMode === 'audio'; // Use file-based transcription path when: Audio Mode + Whisper available + not direct audio model const shouldUseFilePath = (): boolean => isInAudioInterfaceMode() && !!downloadedModelId && !supportsDirectAudio(); - // Ensure whisper is resident before transcribing (the decision lives in the pure - // ensureWhisperForTranscription — it frees a blocking generation model, but never - // evicts on a hard whisper-load failure). ONE seam for EVERY path: the file paths below - // AND the realtime hold-to-talk dictation (injected into useWhisperTranscription), so a - // memory-blocked dictation recovers instead of dead-ending. - const ensureWhisper = (): Promise => ensureWhisperForTranscription({ - isLoaded: () => whisperService.isModelLoaded(), - hasDownloadedModel: () => !!downloadedModelId, - loadWhisper: () => useWhisperStore.getState().loadModel(), - // keepSelection=true so routing reloads the right generation model after the - // transcript decides text-vs-image. - freeGenerationModels: () => activeModelService.unloadAllModels(true).then(() => {}), - }); + const ensureWhisper = createWhisperReadiness(downloadedModelId, remoteTranscriptionAvailable); const { isRecording: isWhisperRecording, isModelLoading, + isStartingRecording, isTranscribing: isWhisperTranscribing, partialResult, finalResult, @@ -88,7 +123,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, const error = directError ?? whisperError; // voiceAvailable: direct audio OR whisper downloaded - const voiceAvailable = supportsDirectAudio() || !!downloadedModelId; + const voiceAvailable = supportsDirectAudio() || !!downloadedModelId || remoteTranscriptionAvailable; useVoiceSessionDriver({ // Hands-free auto-arm is voice-mode only (a global setting leaves the session in `listen`, so @@ -121,7 +156,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, recordingConversationIdRef.current = conversationId || null; setDirectError(null); - if (supportsDirectAudio()) { + if (supportsDirectAudio() || remoteTranscriptionAvailable) { try { setIsDirectRecording(true); await audioRecorderService.startRecording(); @@ -161,11 +196,11 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, const transcribeRecordedFile = async (path: string, errLabel: string): Promise<{ whisperReady: boolean; transcript: string }> => { let whisperReady = false; let transcript = ''; - if (downloadedModelId) { + if (downloadedModelId || remoteTranscriptionAvailable) { setIsTranscribingFile(true); try { whisperReady = await ensureWhisper(); - if (whisperReady) transcript = await whisperService.transcribeFile(path); + if (whisperReady) transcript = await whisperService.transcribeFile(path, { language: transcriptionLanguage }); } catch (err) { logger.error(errLabel, err); } setIsTranscribingFile(false); } @@ -198,7 +233,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, // output. A person tapping the mic resumes it. voiceSession.dispatch('nothingHeard'); voiceSession.dispatch('nothingHeard'); - setDirectError(outcome.message); + setDirectError(outcome.message); setTimeout(() => setDirectError(null), 3000); } } else { @@ -217,7 +252,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, // output. A person tapping the mic resumes it. voiceSession.dispatch('nothingHeard'); voiceSession.dispatch('nothingHeard'); - setDirectError(outcome.message); + setDirectError(outcome.message); setTimeout(() => setDirectError(null), 3000); } } @@ -243,7 +278,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, let transcript = ''; try { whisperReady = await ensureWhisper(); - if (whisperReady) transcript = await whisperService.transcribeFile(path); + if (whisperReady) transcript = await whisperService.transcribeFile(path, { language: transcriptionLanguage }); } catch (transcribeErr) { logger.error('[Voice] File transcription error:', transcribeErr); } @@ -275,41 +310,49 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, // arrives here. A stop that silence did not cause is a deliberate one, and it suspends hands-free // until the person taps for the floor again. logger.log('[TURN] stop requested'); + const isChatDictation = !isInAudioInterfaceMode(); // The person's turn is over and there is audio to work on, so the assistant takes the floor now - // before any reply exists. That is what keeps the mic shut while it transcribes and thinks. voiceSession.dispatch('turnCaptured'); // Released on EVERY stop path, so a mic that closed can never keep the floor. stopListeningForSilence(); - if (isDirectRecording) { - await stopDirectRecording(); - return; - } + try { + if (isDirectRecording) { + await stopDirectRecording(); + return; + } - if (isAudioModeRecording) { - await stopAudioModeRecording(); - return; - } + if (isAudioModeRecording) { + await stopAudioModeRecording(); + return; + } - await stopWhisperRecording(); + await stopWhisperRecording(); + } finally { + // Chat dictation only edits the draft. It does not start an assistant turn, + // so report its own lifecycle event instead of borrowing Voice-mode reset. + if (isChatDictation) voiceSession.dispatch('dictationFinished'); + } }; const cancelRecording = () => { + const isReplayCancellation = !!voiceSession.current().replayReturnsTo; stopListeningForSilence(); - if (isDirectRecording) { - audioRecorderService.cancelRecording(); - setIsDirectRecording(false); - recordingConversationIdRef.current = null; - return; - } - if (isAudioModeRecording) { - audioRecorderService.cancelRecording(); - setIsAudioModeRecording(false); - recordingConversationIdRef.current = null; - return; + try { + cancelActiveCapture({ + isDirectRecording, + isAudioModeRecording, + setIsDirectRecording, + setIsAudioModeRecording, + stopWhisperRecording, + clearWhisperResult: clearResult, + clearConversation: () => { recordingConversationIdRef.current = null; }, + }); + } finally { + // A user cancellation ends the manual turn. A replay cancellation is + // different: replayStarted already owns the floor and replayEnded returns it. + if (!isReplayCancellation) voiceSession.dispatch('dictationFinished'); } - stopWhisperRecording(); - clearResult(); - recordingConversationIdRef.current = null; }; // Register this recorder's concrete intents with the single recording-controller @@ -363,6 +406,7 @@ export function useVoiceInput({ conversationId, onTranscript, onAudioAttachment, isRecording, isAwaitingSpeech: silence.isAwaitingSpeech, isModelLoading, + isStartingRecording, isTranscribing, partialResult, error, diff --git a/src/components/ChatInput/ensureWhisperForTranscription.ts b/src/components/ChatInput/ensureWhisperForTranscription.ts index 8b80ba0dc..31f2e9141 100644 --- a/src/components/ChatInput/ensureWhisperForTranscription.ts +++ b/src/components/ChatInput/ensureWhisperForTranscription.ts @@ -17,14 +17,17 @@ import type { WhisperLoadResult } from '../../stores/whisperStore'; * user's generation model (that would strand them with nothing loaded). */ export interface WhisperReadinessDeps { - isLoaded: () => boolean; + /** True only when the model selected for this turn is the resident context. */ + isSelectedModelLoaded?: () => boolean; + /** Compatibility for callers that do not yet track resident model identity. */ + isLoaded?: () => boolean; hasDownloadedModel: () => boolean; loadWhisper: () => Promise; freeGenerationModels: () => Promise; } export async function ensureWhisperForTranscription(deps: WhisperReadinessDeps): Promise { - if (deps.isLoaded()) return true; + if (deps.isSelectedModelLoaded?.() ?? deps.isLoaded?.() ?? false) return true; if (!deps.hasDownloadedModel()) return false; const first = await deps.loadWhisper(); diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx index b5476c44f..3a5165b27 100644 --- a/src/components/ChatInput/index.tsx +++ b/src/components/ChatInput/index.tsx @@ -3,7 +3,7 @@ import { View, TextInput, TouchableOpacity, Animated, Platform, ActionSheetIOS } import Icon from 'react-native-vector-icons/Feather'; import { useTheme, useThemedStyles } from '../../theme'; import { ImageModeState, MediaAttachment } from '../../types'; -import { VoiceRecordButton } from '../VoiceRecordButton'; +import { VoiceRecordButton, type VoiceRecordInteractionMode } from '../VoiceRecordButton'; import { RecordingHint } from './RecordingHint'; import { ComposerIconsRow } from './ComposerIconsRow'; import { triggerHaptic } from '../../utils/haptics'; @@ -65,6 +65,22 @@ const IMAGE_MODE_CYCLE: ImageModeState[] = ['auto', 'force', 'disabled']; // (collapsing) row — it's rendered persistently above the input instead. const computePillIconsWidth = (): number => PILL_ICON_SIZE * 2; +type VoiceProcessingState = 'loading' | 'starting' | 'transcribing' | undefined; + +/** Project the recorder lifecycle into one display state. */ +const deriveVoiceProcessingState = (input: { + isRecording: boolean; + isModelLoading: boolean; + isStartingRecording: boolean; + isTranscribing: boolean; +}): VoiceProcessingState => { + if (input.isRecording) return undefined; + if (input.isModelLoading) return 'loading'; + if (input.isStartingRecording) return 'starting'; + if (input.isTranscribing) return 'transcribing'; + return undefined; +}; + /** * Alert shown when the user attaches an image to a model without vision support. * Remote (server) models have no local vision-projector file to repair, so the @@ -137,6 +153,7 @@ export const ChatInput: React.FC = ({ const styles = useThemedStyles(createStyles); const [message, setMessage] = useState(''); const [imageMode, setImageMode] = useState('auto'); + const [voiceInteractionMode, setVoiceInteractionMode] = useState('idle'); const [alertState, setAlertState] = useState(initialAlertState); const quickSettings = useKeyboardAwarePopover(); const attachPicker = useKeyboardAwarePopover(); @@ -178,12 +195,20 @@ export const ChatInput: React.FC = ({ }), }); - const { isRecording, isModelLoading, isTranscribing, partialResult, error, voiceAvailable, isAwaitingSpeech, startRecording, stopRecording, cancelRecording } = useVoiceInput({ + const { isRecording, isModelLoading, isStartingRecording, isTranscribing, partialResult, error, voiceAvailable, isAwaitingSpeech, startRecording, stopRecording, cancelRecording } = useVoiceInput({ conversationId, + interfaceMode, onTranscript: voiceHandlers.onTranscript, onAudioAttachment: voiceHandlers.onAudioAttachment, onAutoSend: voiceHandlers.onAutoSend, }); + const voiceProcessingState = deriveVoiceProcessingState({ + isRecording, + isModelLoading, + isStartingRecording, + isTranscribing, + }); + const showVoiceStatus = isRecording || voiceInteractionMode !== 'idle' || voiceProcessingState !== undefined; const { settings: appSettings, updateSettings: updateAppSettings } = useAppStore(); const thinkingEnabled = appSettings.thinkingEnabled; @@ -349,6 +374,7 @@ export const ChatInput: React.FC = ({ onStartRecording={startRecording} onStopRecording={stopRecording} onCancelRecording={cancelRecording} + onInteractionModeChange={setVoiceInteractionMode} /> ); @@ -362,9 +388,12 @@ export const ChatInput: React.FC = ({ /> - {isRecording ? ( - // Push-to-talk hint inline in the composer (WhatsApp pattern) — see RecordingHint. - + {showVoiceStatus ? ( + ) : ( <> = ({ ); }; - diff --git a/src/components/ChatMessage/components/ToolMessages.tsx b/src/components/ChatMessage/components/ToolMessages.tsx index 032535602..90c0fec46 100644 --- a/src/components/ChatMessage/components/ToolMessages.tsx +++ b/src/components/ChatMessage/components/ToolMessages.tsx @@ -11,6 +11,7 @@ import { Text, TouchableOpacity, View } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; import { useTheme } from '../../../theme'; import { useAccordionExpanded } from '../../../stores'; +import { SLOTS, useSlot } from '../../../bootstrap/slotRegistry'; import { CustomAlert, type AlertState } from '../../CustomAlert'; import { MarkdownText } from '../../MarkdownText'; import { ToolsSentCollapsible } from './ToolsSentCollapsible'; @@ -20,7 +21,10 @@ import type { Message } from '../../../types'; function getToolIcon(toolName?: string): string { switch (toolName) { case 'web_search': + case 'web_use': return 'globe'; + case 'computer_use': + return 'monitor'; case 'calculator': return 'hash'; case 'get_current_datetime': @@ -47,11 +51,22 @@ function getToolLabel(toolName?: string, content?: string): string { return 'Retrieved date/time'; case 'get_device_info': return 'Retrieved device info'; + case 'web_use': + return 'Web Use'; + case 'computer_use': + return 'Computer Use'; default: return toolName || 'Tool result'; } } +function isTaskToolName(toolName?: string): boolean { + return ( + toolName === 'web_use' || + toolName === 'computer_use' + ); +} + type ToolResultBubbleProps = { /** Stable identity for persisting expanded state across the streaming→finalized * remount (not the message id, which changes on finalize). */ @@ -75,6 +90,7 @@ type ToolResultBubbleProps = { paired?: boolean; styles: ReturnType; colors: any; + detail?: React.ReactNode; }; const ToolResultBubbleInner: React.FC = ({ @@ -91,6 +107,7 @@ const ToolResultBubbleInner: React.FC = ({ paired = false, styles, colors, + detail, }) => { const [expanded, toggle] = useAccordionExpanded(`tool-result:${stableKey}`); const tone = active ? colors.primary : colors.textMuted; @@ -104,6 +121,7 @@ const ToolResultBubbleInner: React.FC = ({ onPress={hasDetails ? toggle : undefined} activeOpacity={hasDetails ? 0.6 : 1} disabled={!hasDetails} + testID={`tool-result-accordion-${toolName || 'unknown'}`} > = ({ {expanded && hasDetails && ( - {content} + {detail ?? {content}} )} @@ -167,15 +185,21 @@ export const ToolResultMessage: React.FC<{ styles: any; colors: any; }> = ({ message, styles, colors }) => { + const TaskToolDetail = useSlot(SLOTS.taskToolDetail); const toolIcon = getToolIcon(message.toolName); const toolLabel = getToolLabel(message.toolName, message.content); const durationLabel = message.generationTimeMs == null ? '' : ` (${message.generationTimeMs}ms)`; - const hasDetails = !!( - message.content && - message.content.length > 0 && - !message.content.startsWith('No results') - ); + const isTaskTool = isTaskToolName(message.toolName); + const taskDetail = + isTaskTool && TaskToolDetail ? : null; + const hasDetails = isTaskTool + ? Boolean(TaskToolDetail) + : !!( + message.content && + message.content.length > 0 && + !message.content.startsWith('No results') + ); // Prefer toolCallId (carried on every tool-result message and stable across the // streaming→finalized remount); fall back to the message id. const stableKey = message.toolCallId || message.id; @@ -195,6 +219,7 @@ export const ToolResultMessage: React.FC<{ hasDetails={hasDetails} styles={styles} colors={colors} + detail={taskDetail} /> @@ -205,32 +230,51 @@ export const SyncedToolArtifacts: React.FC<{ message: Message; styles: ReturnType; colors: ReturnType['colors']; -}> = ({ message, styles, colors }) => ( - <> - {message.toolArtifacts?.map((artifact, index) => { - const running = artifact.status === 'running'; - return ( - 0} - active={running} - styles={styles} - colors={colors} - /> - ); - })} - -); +}> = ({ message, styles, colors }) => { + const TaskToolDetail = useSlot(SLOTS.taskToolDetail); + return ( + <> + {message.toolArtifacts?.map((artifact, index) => { + const running = artifact.status === 'running'; + const isTaskTool = isTaskToolName(artifact.name); + const taskDetail = + isTaskTool && TaskToolDetail ? ( + + ) : null; + return ( + 0 + } + active={running} + styles={styles} + colors={colors} + detail={taskDetail} + /> + ); + })} + + ); +}; /** * The calls an assistant turn asked for, one row each. @@ -253,12 +297,13 @@ export const ToolCallMessage: React.FC<{ } catch { argsPreview = tc.arguments; } + const argsLabel = argsPreview ? `: ${argsPreview}` : ''; return ( = ({ @@ -40,6 +41,7 @@ export const TextGenerationSection: React.FC = () => { {basicSettings.map(setting => ( ))} + {!isLiteRT && } = ( + {/* Voice mode ends a turn on silence. Lives with STT because it is about listening. */} diff --git a/src/components/MarkdownText.tsx b/src/components/MarkdownText.tsx index 1e13f8c1f..584899a20 100644 --- a/src/components/MarkdownText.tsx +++ b/src/components/MarkdownText.tsx @@ -1,11 +1,15 @@ import React, { useCallback, useMemo } from 'react'; import { Linking, Text } from 'react-native'; -import Markdown from '@ronradtke/react-native-markdown-display'; -import { preprocessChatMarkdown } from '@offgrid/sync'; +import Markdown, { + MarkdownIt, +} from '@ronradtke/react-native-markdown-display'; +import { preprocessChatMarkdown, safeChatExternalUrl } from '@offgrid/sync'; import { useTheme } from '../theme'; import type { ThemeColors } from '../theme'; import { TYPOGRAPHY, SPACING, FONTS } from '../constants'; +const chatMarkdownParser = MarkdownIt({ typographer: true, linkify: true }); + /** * Escape asterisks used as multiplication operators (digit*digit) so * markdown-it doesn't treat them as emphasis markers. @@ -86,7 +90,8 @@ export function MarkdownText({ children, dimmed }: MarkdownTextProps) { ); const handleLinkPress = useCallback((url: string) => { - Linking.openURL(url); + const safeUrl = safeChatExternalUrl(url); + if (safeUrl) void Linking.openURL(safeUrl); return false; }, []); @@ -99,6 +104,7 @@ export function MarkdownText({ children, dimmed }: MarkdownTextProps) { return ( diff --git a/src/components/ModelCard.tsx b/src/components/ModelCard.tsx index 86c951f97..05d668feb 100644 --- a/src/components/ModelCard.tsx +++ b/src/components/ModelCard.tsx @@ -16,6 +16,7 @@ import { } from './ModelCardContent'; import { QUEUED_ICON } from '../utils/downloadStatusIcon'; import { formatBytes } from '../utils/formatBytes'; +import { presentProgress } from '../utils/progressPresentation'; interface ModelCardProps { model: { @@ -39,7 +40,7 @@ interface ModelCardProps { * 0% progress bar, so the user gets clear feedback the tap registered. */ isQueued?: boolean; downloadProgress?: number; - downloadBytes?: { downloaded: number; total: number }; + downloadBytes?: { downloaded: number; total: number; bytesPerSecond?: number }; /** Concurrent downloads behind this card (main+mmproj / grouped) → "N downloads". */ downloadCount?: number; isActive?: boolean; @@ -87,23 +88,34 @@ function resolveCredibility( const DownloadProgressSection: React.FC<{ progress: number; - bytes?: { downloaded: number; total: number }; + bytes?: { downloaded: number; total: number; bytesPerSecond?: number }; queued?: boolean; /** Number of concurrent downloads behind this card (>1 → show "N downloads"). */ count?: number; }> = ({ progress, bytes, queued, count }) => { const styles = useThemedStyles(createStyles); const { colors } = useTheme(); - const bytesLabel = bytes && bytes.total > 0 ? `${formatBytes(bytes.downloaded)} / ${formatBytes(bytes.total)}` : ''; + const presented = presentProgress({ + progress, + bytesDownloaded: bytes?.downloaded, + totalBytes: bytes?.total, + bytesPerSecond: bytes?.bytesPerSecond, + status: queued ? 'pending' : 'running', + }); + const percentage = presented.progress.percentage ?? 0; // Cumulative download → note how many files are running so the total reads clearly. const countLabel = count && count > 1 ? `${count} downloads` : ''; - const caption = [bytesLabel, countLabel].filter(Boolean).join(' · '); + const caption = [ + presented.bytesText, + queued ? undefined : presented.rateText, + countLabel, + ].filter(Boolean).join(' · '); return ( {/* Full-width bar so it uses the whole card width. Queued shows an EMPTY bar (0 progress) so it reads as "not started yet". */} - + {/* Caption row under the bar: bytes (+ "N downloads") on the LEFT, status on the RIGHT. "Queued" while waiting for a slot, otherwise the percent. */} @@ -115,7 +127,7 @@ const DownloadProgressSection: React.FC<{ Queued ) : ( - {`${Math.round(progress * 100)}%`} + {presented.percentageText ?? 'In progress'} )} @@ -131,14 +143,19 @@ const FailedSection: React.FC<{ }> = ({ errorMessage, bytesDownloaded, totalBytes, onRetry, onRemove }) => { const styles = useThemedStyles(createStyles); const { colors } = useTheme(); - const progress = totalBytes > 0 ? bytesDownloaded / totalBytes : 0; + const presented = presentProgress({ + bytesDownloaded, + totalBytes, + status: 'failed', + }); + const progress = presented.progress.percentage ?? 0; return ( - + - {Math.round(progress * 100)}% + {presented.percentageText ?? 'Stopped'} {totalBytes > 0 && ( {formatBytes(bytesDownloaded)} / {formatBytes(totalBytes)} @@ -309,4 +326,3 @@ function formatNumber(num: number): string { if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; return num.toString(); } - diff --git a/src/components/ModelSelectorModal/index.tsx b/src/components/ModelSelectorModal/index.tsx index 0d8b7da87..7fcf521b5 100644 --- a/src/components/ModelSelectorModal/index.tsx +++ b/src/components/ModelSelectorModal/index.tsx @@ -13,7 +13,7 @@ import { useLoadedTextModelPath } from '../../hooks/useLoadedTextModelPath'; import { useActiveModelStatus } from '../../hooks/useActiveModelStatus'; import { loadingTextRowId } from './rowState'; import { DownloadedModel, ONNXImageModel, RemoteModel } from '../../types'; -import { activeModelService, llmService, remoteServerManager } from '../../services'; +import { activeModelService, remoteServerManager } from '../../services'; import { loadModelWithOverride } from '../../services/loadModelWithOverride'; import { CustomAlert, AlertState, initialAlertState, showAlert } from '../CustomAlert'; import { createAllStyles } from './styles'; @@ -159,10 +159,9 @@ export const ModelSelectorModal: React.FC = ({ // Handle selecting a remote text model const handleSelectRemoteTextModel = async (model: RemoteModel, serverId: string) => { try { - // Unload any active local model first — only one active model at a time - if (llmService.isModelLoaded()) { - await activeModelService.unloadTextModel(); - } + // Always go through the owner. It also waits for an in-flight local load, + // which is not yet visible as a loaded native model. + await activeModelService.unloadTextModel(); await remoteServerManager.setActiveRemoteTextModel(serverId, model.id); onSelectionComplete?.(); } catch (error) { diff --git a/src/components/RemoteServerModal/styles.ts b/src/components/RemoteServerEditor/styles.ts similarity index 95% rename from src/components/RemoteServerModal/styles.ts rename to src/components/RemoteServerEditor/styles.ts index c16eadb60..2ff0be736 100644 --- a/src/components/RemoteServerModal/styles.ts +++ b/src/components/RemoteServerEditor/styles.ts @@ -2,7 +2,7 @@ import type { ThemeColors, ThemeShadows } from '../../theme/palettes'; import { SPACING, TYPOGRAPHY } from '../../constants'; /** - * The add / edit server sheet, on the same tokens as the rest of the app. + * The full-screen server editor, on the same tokens as the rest of the app. * * This file named no token at all before: fourteen hardcoded font sizes with no family, seven * weights of 500 or 600 against a bar of 400, and about thirty magic spacings. So the sheet drew @@ -12,8 +12,12 @@ import { SPACING, TYPOGRAPHY } from '../../constants'; */ export function createStyles(colors: ThemeColors, _shadows: ThemeShadows) { return { + screen: { + flex: 1, + backgroundColor: colors.background, + }, container: { - // No flex: 1 - let content size naturally with enableDynamicSizing + flex: 1, }, content: { paddingHorizontal: SPACING.lg, diff --git a/src/components/RemoteServerModal/useRemoteServerForm.ts b/src/components/RemoteServerEditor/useRemoteServerForm.ts similarity index 73% rename from src/components/RemoteServerModal/useRemoteServerForm.ts rename to src/components/RemoteServerEditor/useRemoteServerForm.ts index 727c87621..8ab4b1395 100644 --- a/src/components/RemoteServerModal/useRemoteServerForm.ts +++ b/src/components/RemoteServerEditor/useRemoteServerForm.ts @@ -17,6 +17,9 @@ export function useRemoteServerForm({ server, visible, onSave, onClose }: FormOp const [endpoint, setEndpoint] = useState(''); const [apiKey, setApiKey] = useState(''); const [notes, setNotes] = useState(''); + const [imageModelId, setImageModelId] = useState(''); + const [transcriptionModelId, setTranscriptionModelId] = useState(''); + const [voiceModelId, setVoiceModelId] = useState(''); const [errors, setErrors] = useState>({}); const [isTesting, setIsTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); @@ -30,6 +33,9 @@ export function useRemoteServerForm({ server, visible, onSave, onClose }: FormOp setName(server.name); setEndpoint(server.endpoint); setNotes(server.notes || ''); + setImageModelId(server.mediaModels?.image || ''); + setTranscriptionModelId(server.mediaModels?.transcription || ''); + setVoiceModelId(server.mediaModels?.voice || ''); // Load existing API key from keychain so user can see it's set remoteServerManager.getApiKey(server.id).then((key) => { if (!cancelled) setApiKey(key || ''); @@ -40,6 +46,9 @@ export function useRemoteServerForm({ server, visible, onSave, onClose }: FormOp setEndpoint(''); setApiKey(''); setNotes(''); + setImageModelId(''); + setTranscriptionModelId(''); + setVoiceModelId(''); } setErrors({}); setTestResult(null); @@ -79,6 +88,13 @@ export function useRemoteServerForm({ server, visible, onSave, onClose }: FormOp if (result.models && result.models.length > 0) { setDiscoveredModels(result.models); } + if (result.mediaModels) { + setImageModelId((current) => current || result.mediaModels?.image || ''); + setTranscriptionModelId( + (current) => current || result.mediaModels?.transcription || '', + ); + setVoiceModelId((current) => current || result.mediaModels?.voice || ''); + } } else { const triedUrl = `${endpoint.replace(/\/+$/, '')}/v1/models`; setTestResult({ success: false, message: `${result.error || 'Connection failed'}\nTried: ${triedUrl}` }); @@ -92,28 +108,58 @@ export function useRemoteServerForm({ server, visible, onSave, onClose }: FormOp const saveServer = useCallback(async () => { try { + const mediaModels = { + ...(imageModelId.trim() ? { image: imageModelId.trim() } : {}), + ...(transcriptionModelId.trim() + ? { transcription: transcriptionModelId.trim() } + : {}), + ...(voiceModelId.trim() ? { voice: voiceModelId.trim() } : {}), + }; if (server) { - await remoteServerManager.updateServer(server.id, { name, endpoint, notes, apiKey }); + await remoteServerManager.updateServer(server.id, { + name, + endpoint, + notes, + apiKey, + mediaModels, + }); if (discoveredModels.length > 0) { useRemoteServerStore.getState().setDiscoveredModels(server.id, discoveredModels); } onSave?.(server); } else { const newServer = await remoteServerManager.addServer({ - name, endpoint, providerType: 'openai-compatible', notes: notes || undefined, apiKey: apiKey || undefined, + name, + endpoint, + providerType: 'openai-compatible', + notes: notes || undefined, + apiKey: apiKey || undefined, + mediaModels, }); if (discoveredModels.length > 0) { useRemoteServerStore.getState().setDiscoveredModels(newServer.id, discoveredModels); } - // Silently probe health so status shows immediately instead of "Unknown" - remoteServerManager.testConnection(newServer.id).catch(() => { }); + // Probe before closing so no network work outlives this editor session. + await remoteServerManager.testConnection(newServer.id).catch(() => undefined); onSave?.(newServer); } onClose(); } catch (error) { setAlertState(showAlert('Error', error instanceof Error ? error.message : 'Failed to save server')); } - }, [server, name, endpoint, apiKey, notes, discoveredModels, onSave, onClose]); + }, [ + server, + name, + endpoint, + apiKey, + notes, + imageModelId, + transcriptionModelId, + voiceModelId, + discoveredModels, + onSave, + onClose, + ]); const handleSave = useCallback(async () => { if (!validateForm()) return; @@ -138,6 +184,9 @@ export function useRemoteServerForm({ server, visible, onSave, onClose }: FormOp endpoint, setEndpoint, apiKey, setApiKey, notes, setNotes, + imageModelId, setImageModelId, + transcriptionModelId, setTranscriptionModelId, + voiceModelId, setVoiceModelId, errors, isTesting, testResult, diff --git a/src/components/RemoteServerModal/index.tsx b/src/components/RemoteServerModal/index.tsx deleted file mode 100644 index 4f374b1e1..000000000 --- a/src/components/RemoteServerModal/index.tsx +++ /dev/null @@ -1,212 +0,0 @@ -/** - * Remote Server Configuration Modal - * - * Modal for adding and editing remote LLM server configurations. - */ - -import React, { useState } from 'react'; -import { - View, - Text, - TextInput, - TouchableOpacity, - ScrollView, -} from 'react-native'; -import Icon from 'react-native-vector-icons/Feather'; -import { useTheme, useThemedStyles } from '../../theme'; -import { AppSheet } from '../AppSheet'; -import { Button } from '../Button'; -import { CustomAlert } from '../CustomAlert'; -import { RemoteServer } from '../../types'; -import { createStyles } from './styles'; -import { useRemoteServerForm } from './useRemoteServerForm'; - -interface RemoteServerModalProps { - visible: boolean; - onClose: () => void; - server?: RemoteServer; // For editing existing server - onSave?: (server: RemoteServer) => void; -} - -interface TestResultSectionProps { - testResult: { success: boolean; message: string } | null; - discoveredModels: Array<{ id: string; name: string }>; - styles: ReturnType; -} - -const TestResultSection: React.FC = ({ testResult, discoveredModels, styles }) => ( - <> - {testResult && ( - - - {testResult.message} - - )} - {discoveredModels.length > 0 && ( - - Models found - - {discoveredModels.map((model) => ( - - {model.name} - - ))} - - - )} - -); - -export const RemoteServerModal: React.FC = ({ - visible, - onClose, - server, - onSave, -}) => { - const theme = useTheme(); - const styles = useThemedStyles(createStyles); - - const [showApiKey, setShowApiKey] = useState(false); - - const { - name, setName, - endpoint, setEndpoint, - apiKey, setApiKey, - notes, setNotes, - errors, - isTesting, - testResult, - discoveredModels, - handleTestConnection, - handleSave, - isPublicNetwork, - alertState, - dismissAlert, - } = useRemoteServerForm({ server, visible, onSave, onClose }); - - const handleDonePress = () => { - if (testResult?.success) { - handleSave(); - return; - } - onClose(); - }; - - return ( - - - Server name - - {errors.name && {errors.name}} - - Address - - {errors.endpoint && {errors.endpoint}} - {isPublicNetwork && ( - - {/* An icon, not an emoji: emoji render per-platform and the design system bans them. */} - - - This address is on the public internet. What you type here leaves your network and - goes to whoever runs that server. - - - )} - - {endpoint.trim() - ? `Will connect to: ${endpoint.trim().replace(/\/+$/, '')}/v1/models` - : 'Enter the base address. This app adds /v1/models to it.'} - - - API key (optional) - - - setShowApiKey(v => !v)}> - - - - - Cloud services need this. A server on your own network does not. - - - Notes (optional) - - - - {!testResult?.success && ( - - Test the connection first. That enables {server ? 'Update server' : 'Add server'}. - - )} - - {/* The app's own buttons, not two more hand-built pills. No `loading` prop: it swaps the - label for the platform spinner, which reads as a retry arrow on Android. */} - -