/** * chatHarness — heavy-entry-point setup for UI-level chat integration tests. * * Mounts the REAL ChatScreen and drives it via REAL user actions (type into the real input, press the real * send button), with ONLY the native leaves faked (via nativeBoundary). Everything we own — the screen, * useChatScreen, generationService, the tool loop, the engine services, the stores, residency — runs for * real. This is the "integration test, heavy entry point" contract. * * Usage (the jest.mock for navigation MUST be top-level in the test file — it is hoisted — and points its * route at this module's shared `routeHolder`): * * jest.mock('@react-navigation/native', () => ({ * useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }), * useRoute: () => require('../../harness/chatHarness').routeHolder, * useFocusEffect: () => {}, useIsFocused: () => true, * })); * * const h = await setupChatScreen({ engine: 'llama' }); // installs boundary + loads a real engine * h.render(); // mounts the real ChatScreen * 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 { createDownloadedModel } from '../utils/factories'; /** Shared route params the test's navigation mock reads (set by setupChatScreen). */ export const routeHolder: { params: Record } = { params: {} }; export interface ChatHarnessOptions { engine: 'llama' | 'litert'; /** * 'real': the screens mount inside the app's own NavigationContainer and AppNavigator, so routes, * focus and navigate are the real ones. The test file calls `jest.unmock('@react-navigation/native')` * and declares no navigation mock; Home, the model picker and Chat are reached as a person reaches them. */ navigation?: 'real'; /** 'ios' surfaces the Metal accelerator path for llama; default 'android'. */ platform?: 'ios' | 'android'; ram?: RamProfile; /** Make the model vision-capable so the attach-photo gesture is allowed: LiteRT carries vision in the * bundle; llama gets a projector file beside its weights and a native projector that starts. */ vision?: boolean; /** Make the (LiteRT) model audio-capable (liteRTAudio) — so supportsDirectAudio() is true and chat-mode * hold-to-talk exercises the direct-audio recording path (not the whisper-realtime path). */ audio?: boolean; /** Install the driveable whisper.rn STT fake (for chat-mode voice input flows). */ whisper?: boolean; /** (llama) A llama.rn audio projector starts, as Qwen3-ASR's does (speech-to-text on llama.rn). */ llamaAudio?: boolean; /** Install the stateful background-download native fake (drive DownloadProgress/Complete/Error * events through the real backgroundDownloadService — e.g. an in-flight STT model download). */ download?: boolean; /** Activate the PRO feature set (audio/voice mode header toggle, audio layout, TTS, MCP) via the real * bootstrap, so pro user-flows are reachable in the mounted screen. */ pro?: boolean; /** Skip the deterministic pre-load after model select, so the model is selected-but-not-loaded exactly * as the real lazy flow leaves it (load defers to the first send). Use to assert the lazy-on-select * invariant (no eager warm) via the In Memory section. Default false (pre-load for send determinism). */ deferInitialLoad?: boolean; /** Override the installed model's display name / file name — for flows whose behavior keys on the * model identity (e.g. the name-based capability prediction for a selected-but-not-loaded gguf). */ modelName?: string; modelFileName?: string; /** Override the test model's declared fileSize (drives the residency budget). Default 2GB — a * realistic small model that fits the default 8GB-avail profile. Memory tests set this explicitly. */ modelFileSizeBytes?: number; /** Place a second downloaded model at the native storage boundary for fallback journeys. */ backupModel?: boolean; /** More downloaded models (record + file on disk), listed after the main one — e.g. a model too big * for the device's RAM, to switch to from the chat's model sheet. */ extraModels?: Array<{ id: string; name: string; fileName: string; fileSizeBytes: number; engine?: 'llama' | 'litert' }>; /** The absolute path the main model's saved record names, when it differs from where the file is now: * iOS moves the app's folder on reinstall and update, so a record saved earlier names the OLD folder * while the file sits under the current one. Default: the current path. */ storedModelFilePath?: string; /** (llama) GGUF chat_template on the model context's metadata — drives the REAL Thinking-capability * detection. Omit for the reasoning-capable default; pass a marker-free template (Mistral's tool-use * template) to model a model that does NOT support thinking so the Thinking toggle stays hidden. */ chatTemplate?: string; /** Launch with the FIRST read of this AsyncStorage key failing (an unreadable store on device), as * `relaunchApp` does. Pair with `closeApp()` first to model a relaunch onto persisted storage. */ failFirstReadOf?: string; /** This launch is a RELAUNCH onto saved storage (after closeApp()): the model the user picked before is * still selected, so Home shows it and there is no picker tap; the user goes straight to New Chat. */ relaunched?: boolean; /** (llama) GGUF header fields the native metadata reader returns for the model file. */ llamaModelInfo?: Record; /** Device identity leaves (see InstallOpts): getHardware() and the SoC model. */ deviceHardware?: string; socModel?: string; } /** * The chat as the user reads it: every rendered message bubble, top to bottom, as `role: text` * (the bubble's visible strings joined by a space). Scoped to the bubbles, so the header title * (which repeats the first question) never counts as a message. Assert the whole thread with it: * order, what is shown, and what is not. */ export function renderedThread(view: { queryAllByTestId: (id: RegExp) => unknown[] }): string[] { type Node = { props?: { testID?: string }; children?: Array }; const textOf = (node: Node | string): string[] => typeof node === 'string' ? [node] : (node.children ?? []).flatMap(textOf); return (view.queryAllByTestId(/^(user|assistant)-message$/) as Node[]).map(bubble => { const role = bubble.props?.testID === 'user-message' ? 'user' : 'assistant'; const text = textOf(bubble).map(t => t.trim()).filter(Boolean).join(' '); return `${role}: ${text}`; }); } /** The AsyncStorage key the chat store saves conversations under. */ export const CHAT_STORE_KEY = 'local-llm-chat-storage'; /** * BOUNDARY: the next read of `key` from AsyncStorage fails, once, as an unreadable store does on device; * every other read, and every later read of `key`, goes to the stored data. Patches the AsyncStorage * module of the CURRENT module graph, so call it after a module reset and before the stores are required. * Resolves `failed` once the failing read has been answered. */ export function failFirstStorageRead(key: string): { failed: Promise } { const mod = require('@react-native-async-storage/async-storage'); const AsyncStorage = mod.default ?? mod; const read = AsyncStorage.getItem.getMockImplementation() as (k: string) => Promise; let signal: () => void = () => {}; const failed = new Promise(resolve => { signal = resolve; }); let pending = true; AsyncStorage.getItem.mockImplementation((k: string) => { if (pending && k === key) { pending = false; setTimeout(signal, 0); return Promise.reject(new Error(`I/O error reading ${k}`)); } return read(k); }); return { failed }; } /** * Close the app: stop any reply in flight, unmount every screen, and wait until the chat store's last * write is durable — what is in AsyncStorage is what a relaunch finds. */ export async function closeApp(): Promise { const g = globalThis as unknown as { __GEN_CLEANUP__?: () => Promise; __RTL_CLEANUP__?: () => void; }; await g.__GEN_CLEANUP__?.(); g.__RTL_CLEANUP__?.(); const { waitForChatPersistence } = require('../../src/stores/chatStore'); await waitForChatPersistence(); } /** * fixture (a) — relaunch the app onto its persisted storage. Closes the app (closeApp), then starts a * fresh module graph over a fresh native boundary (llama + filesystem fakes; the in-memory disk starts * empty, AsyncStorage keeps what was saved). The stores hydrate the way they do at launch — from storage, * as they are created; nothing is seeded. `failFirstReadOf` makes that launch's first read of one key fail. * * For a relaunch that goes on to chat with a model, call closeApp() and then * setupChatScreen({ ..., failFirstReadOf }) instead: that is the same fresh launch, plus picking the model. */ export async function relaunchApp(relaunch: { failFirstReadOf?: string } = {}) { await closeApp(); installNativeBoundary({ llama: true, fs: true }); const readFailure = relaunch.failFirstReadOf ? failFirstStorageRead(relaunch.failFirstReadOf) : undefined; const React = require('react'); const rtl = requireRTL(); const { useChatStore } = require('../../src/stores'); if (readFailure) await readFailure.failed; else await rtl.waitFor(() => expect(useChatStore.persist.hasHydrated()).toBe(true)); await rtl.act(() => new Promise(resolve => setTimeout(resolve, 0))); // The relaunched app itself is mounted with real navigation: the list and the chat are its routes. const nav = require('@react-navigation/native'); const navRef = nav.createNavigationContainerRef(); const { useAppStore } = require('../../src/stores'); await rtl.waitFor(() => expect(useAppStore.persist.hasHydrated()).toBe(true)); return { React, rtl, /** Mount the relaunched app on the Chats tab. */ renderChatList() { const { AppNavigator } = require('../../src/navigation'); const app = rtl.render( React.createElement(nav.NavigationContainer, { ref: navRef }, React.createElement(AppNavigator)), ); rtl.act(() => navRef.navigate('Main', { screen: 'ChatsTab' })); return app; }, /** Tap a chat's row on the mounted chat list; the app opens that chat. Returns the app's view. */ async openChatFromList(list: ReturnType, title: string) { rtl.fireEvent.press(list.getByText(title)); await rtl.waitFor(() => expect(navRef.getCurrentRoute()?.name).toBe('Chat')); return list; }, }; } 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, llamaModelInfo: opts.llamaModelInfo, llamaVision: opts.engine === 'llama' && opts.vision, llamaAudio: opts.engine === 'llama' && opts.llamaAudio, deviceHardware: opts.deviceHardware, socModel: opts.socModel, fs: true, ram, whisper: opts.whisper, download: opts.download, }); // The storage read must fail before any store is required: a store reads its saved state as it is created. if (opts.failFirstReadOf) failFirstStorageRead(opts.failFirstReadOf); // 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: () => {}, }; 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 { HomeScreen } = require('../../src/screens/HomeScreen'); const docs = boundary.fs!.DocumentDirectoryPath; 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 // overhead (2.2× on a non-CPU backend, e.g. iOS METAL) needs ~8.8GB and no longer fits the default // 8GB-avail profile — so a chat-flow test (not a memory test) would spuriously hit the fit refusal. // 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; // A llama vision model has its projector file beside the weights, linked on the record. const mmProjFileName = opts.engine === 'llama' && opts.vision ? `mmproj-${fileName}` : undefined; if (mmProjFileName) boundary.fs!.seedFile(`${docs}/models/${mmProjFileName}`, 100 * 1024 * 1024); const model = createDownloadedModel({ id: 'm', ...(mmProjFileName && { mmProjPath: `${docs}/models/${mmProjFileName}`, mmProjFileName, mmProjFileSize: 100 * 1024 * 1024, }), name: opts.modelName ?? 'Test Model', engine: opts.engine, filePath: opts.storedModelFilePath ?? modelPath, fileName, fileSize, liteRTVision: opts.vision, liteRTAudio: opts.audio, }); const downloadedModels = [model]; if (opts.backupModel) { const backupFileName = opts.engine === 'llama' ? 'backup.gguf' : 'backup.litertlm'; const backupPath = `${docs}/models/${backupFileName}`; boundary.fs!.seedFile(backupPath, 500 * 1024 * 1024); downloadedModels.push(createDownloadedModel({ id: 'backup', name: 'Backup Model', engine: opts.engine, filePath: backupPath, fileName: backupFileName, fileSize: fileSize / 2, })); } for (const extra of opts.extraModels ?? []) { const extraPath = `${docs}/models/${extra.fileName}`; boundary.fs!.seedFile(extraPath, 500 * 1024 * 1024); downloadedModels.push(createDownloadedModel({ id: extra.id, name: extra.name, engine: extra.engine ?? opts.engine, filePath: extraPath, fileName: extra.fileName, fileSize: extra.fileSizeBytes, })); } await AsyncStorage.setItem( '@local_llm/downloaded_models', JSON.stringify(downloadedModels), ); 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(); } // Real navigation: the app opens where a returning person's app opens, on Home. Onboarding was finished // on an earlier launch (the store action Onboarding calls), and the downloaded model is known the way // Home learns it (modelManager), so AppNavigator's first route is Main, as on device. const realNav = opts.navigation === 'real'; const nav = realNav ? require('@react-navigation/native') : undefined; const navRef = nav?.createNavigationContainerRef(); const appShell = (beside: React.ReactElement | null = null): React.ReactElement => { const { AppNavigator } = require('../../src/navigation'); const { getSlot, SLOTS } = require('../../src/bootstrap/slotRegistry'); const AppRoot = opts.pro ? getSlot(SLOTS.appRoot) : undefined; // One host root, as App.tsx has: RNTL queries from the first host root, so an element mounted beside // the app (mountBeside) is only found inside it. const { View } = require('react-native'); return React.createElement( View, { style: { flex: 1 } }, AppRoot ? React.createElement(AppRoot, {}) : null, React.createElement(nav.NavigationContainer, { ref: navRef }, React.createElement(AppNavigator)), beside, ); }; /** * Real navigation: a screen or section rendered on its own (the tools list, a settings section) sits as * the one screen of a real React Navigation stack, so its useNavigation / useFocusEffect are the * library's own, not a stub. Without real navigation it renders as before. */ const inNavigator = (element: React.ReactElement): React.ReactElement => { if (!realNav) return element; const { createNativeStackNavigator } = require('@react-navigation/native-stack'); const Stack = createNativeStackNavigator(); return React.createElement( nav.NavigationContainer, null, React.createElement( Stack.Navigator, { screenOptions: { headerShown: false } }, React.createElement(Stack.Screen, { name: 'Host' }, () => element), ), ); }; /** * Real navigation: RNTL fires events only into its current screen, the last render. A section rendered * in its own root (renderAside) takes that over, so when it is unmounted the app shell is the screen again; * otherwise every later tap on the app would be dropped without a word. */ let shell: ReturnType | undefined; const backToShell = () => { if (shell) require('@testing-library/react-native/build/screen').setRenderResult(shell); }; const renderAside = (element: React.ReactElement) => { const aside = rtl.render(inNavigator(element)); const unmount = aside.unmount; aside.unmount = () => { unmount(); backToShell(); }; return aside; }; if (realNav) { const { modelManager } = require('../../src/services'); useAppStore.getState().setDownloadedModels(await modelManager.getDownloadedModels()); useAppStore.getState().setOnboardingComplete(true); } // 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 = realNav ? (shell = rtl.render(appShell())) : rtl.render( React.createElement(HomeScreen, { navigation: { navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {}, }, }), ); await rtl.waitFor( () => { expect(useAppStore.getState().downloadedModels.length).toBeGreaterThan(0); }, { timeout: 4000 }, ); if (!opts.relaunched) { rtl.fireEvent.press( await rtl.waitFor(() => home.getByTestId('browse-models-button')), ); const rows = await rtl.waitFor( () => { const r = home.queryAllByTestId(/^text-model-row-/); 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 }, ); // 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. // Real navigation keeps the app mounted and waits on Home: render() taps New Chat, so the chat screen // mounts when the test opens it, after the test has set up the device (a denied mic, a seeded chat). const newChatButton = await rtl.waitFor(() => home.getByTestId('new-chat-button')); if (!realNav) { rtl.fireEvent.press(newChatButton); home.unmount(); } // Load via the REAL load path (the app loads lazily on the first send; we trigger the same path so the // readiness gate passes deterministically). This is the real native-faked load, not a state shortcut. // deferInitialLoad leaves the model selected-but-not-loaded (the real lazy-on-select state) so a test // can assert nothing is eager-warmed; the first send then triggers the real lazy load. if (!opts.deferInitialLoad) await activeModelService.loadTextModel('m'); // Stop any generation this suite leaves in flight, on THIS module graph, before the next suite resets // modules. Registered the same way requireRTL registers its unmount (a global jest.setup's afterEach // calls), because jest.setup must not require these modules itself - doing so would instantiate them in // the hundred suites that never touch generation. Without it, a suite that ends mid-reply leaves a 50ms // 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__?: () => Promise } ).__GEN_CLEANUP__ = () => generationService.stopGeneration(); } routeHolder.params = {}; // new chat — the first send() creates the conversation return { 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; }, view: null as ReturnType | null, /** * Arrive-via-UI: enable a built-in tool the way the user does — navigate to the Tools tab (a real * separate screen) and flip its switch. Shares the same store as ChatScreen, so the enablement is * 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 = renderAside(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, ); tools.unmount(); }, /** * Arrive-via-UI: set a text-generation SliderSetting (e.g. liteRTTemperature, liteRTTopP) by tapping its * 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 s = renderAside(React.createElement(TextGenerationSection, {})); if (!s.queryByTestId(`setting-${key}-value-button`)) { rtl.fireEvent.press(s.getByTestId('modal-text-advanced-toggle')); } rtl.fireEvent.press(s.getByTestId(`setting-${key}-value-button`)); const input = s.getByTestId(`setting-${key}-input`); rtl.fireEvent.changeText(input, String(value)); rtl.fireEvent(input, 'submitEditing'); s.unmount(); }, /** Let async work (tool loop → tool-result bubble render) settle before asserting. */ async settle(ms = 300) { await new Promise(r => setTimeout(r, ms)); }, /** * Tap the real quick-image-mode toggle once (opens the quick-settings popover, taps the image-mode row). * Cycles auto → ON(force) → OFF(disabled) → auto. Requires an image model (the toggle refuses without * one, alerting "No Image Model"). */ 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')), ); }, /** * Place a DOWNLOADED image model (the native/disk boundary — downloading can't be gestured in jest). It * 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; const { createONNXImageModel } = require('../utils/factories'); 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); 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); else ['unet.bin', 'vae_decoder.bin', 'clip_v2.mnn'].forEach(seedFile); } else { seedFile('model.mlmodelc'); // coreml: a non-empty dir } await this.settle(50); // let the mount's hydration finish clearing the (empty) disk list this.useAppStore.setState({ downloadedImageModels: [imgModel] }); // downloaded (boundary), NOT active return imgModel; }, /** * The whole image-generation journey, through real gestures: place a downloaded image model, force image * mode ON via the real toggle, and send a prompt. * * ONE definition of this journey. Two suites had grown near-identical private copies (imageLightbox's * `generateImage` and an in-flight variant), differing only in whether they wait for the finished image - * which is exactly how a third copy gets written with a subtly different idea of what "generated" means. * * `hold: true` parks the generation INSIDE native generateImage and returns while it is still in flight, so * a test can exercise what the user can do during the many seconds diffusion really takes (press STOP, * watch progress move, tap send again). Release it with `boundary.diffusion.releaseGeneration()` - or by * cancelling, which is what native does. */ async generateImageViaUI( imgOpts: { prompt?: string; backend?: 'mnn' | 'qnn' | 'coreml'; hold?: boolean; } = {}, ) { 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(); }); 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); }); if (hold) { await rtl.waitFor(() => { expect(boundary.diffusion.generationHeld()).toBe(true); }); return; } await rtl.waitFor(() => { expect(this.view!.queryByTestId('generated-image')).not.toBeNull(); }); }, /** * Press the stop control on the image progress card. * * That control has NO testID (ChatScreenComponents: a bare TouchableOpacity around an "x" icon), so it is * reached structurally - the pressable ancestor of the card's "x". The count is asserted first, so if a * second "x" control ever shares the screen this fails loudly instead of quietly pressing the wrong thing. * A testID on that control would delete this helper. */ async pressImageCardStop() { type PressNode = { type?: unknown; props?: Record; parent?: PressNode | null; }; await rtl.act(async () => { // Inside the chat screen only: the whole app (real navigation) has other "x" icons. const chat = this.view!.queryAllByTestId('chat-screen')[0] ?? this.view!.root; const xIcons = chat.findAll( (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; } node = node.parent ?? null; } throw new Error( 'the image progress card\'s "x" has no pressable ancestor - the stop control is dead', ); }); }, /** * Gesture-only send: type into the real input + press the real send button, WITHOUT scripting a turn. * Use when the test scripts multi-turn native output itself (e.g. boundary.litert.scriptTurns([...]) for * a two-pass router). The gesture is identical to send() — only the scripting differs. */ async tapSend(text: string) { const view = this.view!; const input = await rtl.waitFor(() => view.getByTestId('chat-input')); // Drive the composer's REAL onChangeText handler (the same one a keypress invokes). We do NOT use // 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); }); // 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; const pressSend = () => { 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; } n = n.parent ?? null; } rtl.fireEvent.press(view.getByTestId('send-button')); // fallback }; await rtl.act(async () => { pressSend(); }); }, /** * REAL attach-photo gesture: open the attach popover, tap "Photo" — the (faked) native image picker * returns an image, which the real useAttachments hook adds as a pending attachment. Requires a * vision-capable model (setupChatScreen({vision:true})), else the app alerts instead of attaching. */ 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')), ); // 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 this.settle(400); // the handler defers pickFromLibrary via setTimeout(300) await rtl.waitFor(() => { expect(view.queryByTestId('attachments-container')).not.toBeNull(); }); }, /** * Arrive-via-UI: turn on "Show Generation Details" by tapping its real segmented control (the same * control the settings screen renders). Needed to see the per-message details (model, tok/s, tools * sent). NOT settings.updateSettings seeding. */ enableGenerationDetailsViaUI() { const { ShowGenerationDetailsToggle, } = require('../../src/components/settings/textGenAdvancedSections'); const s = renderAside(React.createElement(ShowGenerationDetailsToggle, {})); rtl.fireEvent.press(s.getByTestId('show-gen-details-on-button')); s.unmount(); }, /** * Arrive-via-UI at "the user has a downloaded + selected STT model" (chat-mode voice precondition). * Boundary leaf: the whisper file on disk (a download's artifact). Then run the REAL disk scan * (refreshPresentModels) so the model shows as present, and TAP the present card on the real * 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 { useWhisperStore } = require('../../src/stores/whisperStore'); boundary.fs!.seedFile( `${docs}/whisper-models/ggml-${modelId}.bin`, 75 * 1024 * 1024, ); await useWhisperStore.getState().refreshPresentModels(); // real disk scan → present const t = renderAside(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 }, ); t.unmount(); }, /** 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'), ); // 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, }, }; rtl.fireEvent(btn, 'responderGrant', evt); }, /** 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 evt = { nativeEvent: { touches: [], changedTouches: [], identifier: 1, pageX: 0, pageY: 0, timestamp: 500, }, touchHistory: { touchBank: [], numberActiveTouches: 0, indexOfSingleActiveTouch: -1, mostRecentTimeStamp: 500, }, }; rtl.fireEvent(btn, 'responderRelease', evt); }, /** * Reusable VOICE-MODE (audio interface) entry. Enter voice mode the way a user does: tap the header * Text/Voice dropdown and choose Voice. Pre-places ONLY the boundary leaf a completed voice-model * download leaves (the persisted modelDownloaded flag) — downloading is native and can't be gestured; * the mode-switch gesture runs for real. Requires pro:true + whisper:true. Waits for the audio-mode * record button to render. Reused by every voice/TTS flow test. */ 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, }, }); // 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 }, ); // 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 }, ); }, /** * Voice-send a message in audio mode: the (faked) whisper model transcribes the recorded audio file to * `transcript`, then the REAL audio record button is tapped to START and tapped again to STOP & SEND — * 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 }>; }, ) { 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; }>; }, ); } // BOUNDARY: the whisper model transcribes the recorded audio file to this text. boundary.whisper!.setFileTranscript(transcript); const btn = () => view.getByTestId('voice-record-button-audio'); rtl.fireEvent.press(await rtl.waitFor(btn)); // tap: start recording await this.settle(50); rtl.fireEvent.press(await rtl.waitFor(btn)); // tap: stop & send → transcribeFile → onTranscript → send }, /** 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). */ /** Wrap an element rendered on its own so it has real navigation (see inNavigator above). */ inNavigator, /** Render a section on its own, with real navigation; unmounting it makes the app the screen again. */ renderAside, render() { if (realNav) { // The app is already mounted on Chat (New Chat opened it); a chat chosen by the test opens there. backToShell(); this.view = home; const params = require('./chatHarness').routeHolder.params; if (Object.keys(params).length) rtl.act(() => navRef.navigate('Chat', params)); else if (navRef.getCurrentRoute()?.name !== 'Chat') rtl.fireEvent.press(home.getByTestId('new-chat-button')); return this.view; } this.view = rtl.render(this.screenTree()); return this.view; }, /** * Real navigation only: go to one of the app's routes (ModelSettings, StorageSettings, ...) in the * mounted app, as a tap that navigates there does, and return the app's view. */ async openScreen(name: string, params?: Record) { if (!realNav) throw new Error("openScreen needs setupChatScreen({ navigation: 'real' })"); await rtl.act(async () => navRef.navigate(name, params)); await rtl.waitFor(() => expect(navRef.getCurrentRoute()?.name).toBe(name)); this.view = home; return home; }, /** * Real navigation only: mount an element beside the app, as one more child of the app's root (a probe * that reads the stores). The app stays mounted where it is. */ mountBeside(element: React.ReactElement) { if (!realNav) throw new Error("mountBeside needs setupChatScreen({ navigation: 'real' })"); shell!.rerender(appShell(element)); }, /** Real navigation only: open one of Home's tabs (ChatsTab, ProjectsTab, ...), as tapping it does. */ async openTab(tab: string) { if (!realNav) throw new Error("openTab needs setupChatScreen({ navigation: 'real' })"); await rtl.act(async () => navRef.navigate('Main', { screen: tab })); await rtl.waitFor(() => expect(navRef.getCurrentRoute()?.name).toBe(tab)); this.view = home; return home; }, /** Real navigation only: open the Chats tab in the mounted app; returns the app's view. */ openChatList() { return this.openTab('ChatsTab'); }, /** Real navigation only: the route the app is on (the leaf route, inside Home's tabs). */ currentRoute(): string | undefined { return navRef?.getCurrentRoute()?.name; }, /** The mounted tree: ChatScreen, plus the app.root slot when pro is active. Fresh elements each call. */ screenTree(): React.ReactElement { const { ChatScreen } = require('../../src/screens/ChatScreen'); const { getSlot, SLOTS } = require('../../src/bootstrap/slotRegistry'); const AppRoot = opts.pro ? getSlot(SLOTS.appRoot) : undefined; return AppRoot ? React.createElement( React.Fragment, null, React.createElement(AppRoot, {}), React.createElement(ChatScreen, {}), ) : React.createElement(ChatScreen, {}); }, /** * Open another chat on the SAME mounted ChatScreen, as navigating to a chat from the list does: the * native stack keeps the screen and hands it new route params. No id opens a new chat. Anything * still in flight in the composer (a transcription, a send) stays alive across the switch. */ async openConversation(conversationId?: string) { if (realNav) { // The native stack keeps the Chat screen and hands it the new params. await rtl.act(async () => navRef.navigate('Chat', conversationId ? { conversationId } : {})); await rtl.waitFor(() => { expect(useChatStore.getState().activeConversationId).toBe(conversationId ?? null); }); return; } // The screen reads the route through the module registry the harness reset into, which holds its // own copy of this module; set the params there. require('./chatHarness').routeHolder.params = conversationId ? { conversationId } : {}; await rtl.act(async () => { this.view!.rerender(this.screenTree()); }); await rtl.waitFor(() => { expect(useChatStore.getState().activeConversationId).toBe(conversationId ?? null); }); }, /** * Leave this chat for Home and tap New Chat, as the user does: the chat screen closes, Home's real * New Chat button opens a fresh chat screen with no conversation (created on the first send). */ async newChatViaHome() { if (realNav) { // Back to Home with the chat's own Back button, then Home's real New Chat button. rtl.fireEvent.press(home.getByLabelText('Back')); await rtl.waitFor(() => expect(navRef.getCurrentRoute()?.name).toBe('HomeTab')); rtl.fireEvent.press(await rtl.waitFor(() => home.getByTestId('new-chat-button'))); await rtl.waitFor(() => { expect(navRef.getCurrentRoute()?.name).toBe('Chat'); expect(useChatStore.getState().activeConversationId).toBeNull(); }); return; } this.view?.unmount(); const homeView = rtl.render( React.createElement(HomeScreen, { navigation: { navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }, }), ); rtl.fireEvent.press(await rtl.waitFor(() => homeView.getByTestId('new-chat-button'))); homeView.unmount(); require('./chatHarness').routeHolder.params = {}; this.render(); await rtl.waitFor(() => { expect(useChatStore.getState().activeConversationId).toBeNull(); }); }, /** * Script the next engine turn, then drive the real user send: type into the real input, press the real * 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 }[]; }, ); const view = this.view!; const input = await rtl.waitFor(() => view.getByTestId('chat-input')); // Drive the composer's REAL onChangeText handler (the same one a keypress invokes). We do NOT use // 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); }); // 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; const pressSend = () => { 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; } n = n.parent ?? null; } rtl.fireEvent.press(view.getByTestId('send-button')); // fallback }; await rtl.act(async () => { pressSend(); }); }, /** * Open the REAL action menu for the last message of `role`, via the requested affordance: * - 'longpress' → long-press the message bubble * - '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', ) { 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 target = bubbles[bubbles.length - 1]; if (via === 'longpress') { rtl.fireEvent(target, 'longPress'); } else { // Generation can replace the final assistant row while this wait runs. Re-read the last // row so the gesture reaches the current message instead of a detached render node. const dots = await rtl.waitFor(() => { const currentBubbles = view.queryAllByTestId(testId); const currentTarget = currentBubbles[currentBubbles.length - 1]; const scopedDots = rtl.within(currentTarget).queryByText('•••'); if (scopedDots) return scopedDots; // Audio mode owns its full message bubble outside the core role wrapper. Its latest // visible action control still belongs to the last rendered message in this journey. const visibleDots = view.getAllByText('•••'); return visibleDots[visibleDots.length - 1]; }); rtl.fireEvent.press(dots); } 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 }); else boundary.litert.scriptTurn(scripted as { content?: string }); await this.openActionMenu('assistant', via); rtl.fireEvent.press(this.view!.getByTestId('action-retry')); }, /** * Switch this chat to a REMOTE (OpenAI-compatible) model the way the user does: a Desktop server is * added and its model list discovered (the network is the boundary: `/v1/models` answers over a fake * fetch), then the user opens the chat's model selector and taps the remote model. Replies then come * over the streaming XHR boundary: queue each with remoteHarness.installRemoteStream before the send. * Returns a cleanup that restores fetch and removes the server. */ async selectRemoteModelViaUI(remote: { modelId?: string; endpoint?: string } = {}) { const { modelId = 'gemini', endpoint = 'http://localhost:1234' } = remote; const view = this.view!; const { remoteServerManager } = require('../../src/services/remoteServerManager'); const { useRemoteServerStore } = require('../../src/stores'); const originalFetch = global.fetch; global.fetch = (async (input: RequestInfo | URL) => { if (String(input).includes('/v1/models')) { return { ok: true, json: async () => ({ object: 'list', data: [{ id: modelId, object: 'model', owned_by: 'test', kind: 'chat' }], }), } as Response; } return { ok: false, status: 404, json: async () => ({}) } as Response; }) as typeof global.fetch; const server = await remoteServerManager.addServer({ name: 'Desktop', endpoint, providerType: 'openai-compatible', }); await remoteServerManager.testConnection(server.id); rtl.fireEvent.press(view.getByTestId('model-selector')); rtl.fireEvent.press(await rtl.waitFor(() => view.getByTestId('models-row-text'))); rtl.fireEvent.press(await rtl.waitFor(() => view.getByText(modelId))); await rtl.waitFor(() => { expect(useRemoteServerStore.getState().activeRemoteTextModelId).toBe(modelId); }); return async () => { global.fetch = originalFetch; await remoteServerManager.clearAllServers(); }; }, /** * 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 }); 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...'), ); rtl.fireEvent.changeText(input, newText); rtl.fireEvent.press(view.getByText('SAVE & RESEND')); }, }; }