import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, useLocation } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { I18nProvider } from '@/i18n' import { $localRuntimeJobs } from '@/store/local-runtime-jobs' import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes' import { LocalModelsSettings } from './local-models-settings' // Mock the API layer — the pane's contract is what it RENDERS from these // payloads, not transport. vi.mock('@/hermes', () => ({ activateLocalModel: vi.fn(), deleteLocalModel: vi.fn(), downloadBrowsedModel: vi.fn(), downloadLocalModel: vi.fn(), ejectLocalModel: vi.fn(), getLocalCatalog: vi.fn(), getLocalHardware: vi.fn(), getLocalModelsJobs: vi.fn(), getLocalModelsStatus: vi.fn(), getLocalRuntimeJob: vi.fn(), installLocalRuntime: vi.fn(), listHFRepoFiles: vi.fn(), quickstartLocalModels: vi.fn(), searchHFModels: vi.fn(), sideloadLocalModel: vi.fn() })) import * as hermes from '@/hermes' const mocked = vi.mocked(hermes) const BASE_STATUS: LocalModelsStatus = { enabled: true, tag: 'b10290', configured_tag: 'b10290', update_available: false, runtime_installed: false, runtime_backend: null, server_running: false, server_base_url: null, active_model_id: null, loaded_models: {}, models: [], models_dir: 'C:/somewhere/models' } const BASE_HARDWARE: LocalHardware = { uma: false, vram_total_bytes: 32 * 2 ** 30, vram_usable_bytes: 26 * 2 ** 30, ram_total_bytes: 256 * 2 ** 30, ram_available_bytes: 200 * 2 ** 30, vram_label: '32.0 GB', gpu_name: 'NVIDIA GeForce RTX 5090', gpu_util_percent: 12, vram_used_bytes: 6 * 2 ** 30 } const FITTING_MODEL: LocalCatalogModel = { id: 'Qwen3.6-27B-UD-Q4_K_XL', display_name: 'Qwen3.6 27B', description: 'Best all-round agent model; long context stays fast', size_bytes: 17.6 * 2 ** 30, size_label: '17.6 GB', native_context: 262144, native_context_label: '256K', recommended: true, downloaded: false, mtp: false, fits: true, fit_summary: 'runs at its full 256K context', start_window: 262144, start_window_label: '256K', spilled: false } const SPILLED_MODEL: LocalCatalogModel = { ...FITTING_MODEL, id: 'Spilled-Model', display_name: 'Spilled Model', recommended: false, fits: true, spilled: true, start_window: 65536, start_window_label: '64K', fit_summary: 'starts at 64K and grows toward 256K as you use it (larger than your GPU memory — runs slower)' } const REFUSED_MODEL: LocalCatalogModel = { ...FITTING_MODEL, id: 'Huge-Model', display_name: 'Huge Model', recommended: false, fits: false, fit_summary: 'Needs more memory than this machine has', fit_detail: 'needs ~60 GiB at the 64K floor', start_window: undefined, start_window_label: undefined } function renderPane() { return render( ) } // The fresh-machine states these tests exercise now lead with the // quickstart card; the full pane (runtime rows, model list, browser) // is one 'Configure…' click away. Render and click through. async function renderFullPane() { const result = renderPane() const configure = await screen.findByRole('button', { name: /configure/i }) fireEvent.click(configure) return result } beforeEach(() => { mocked.getLocalModelsStatus.mockResolvedValue(BASE_STATUS) mocked.getLocalHardware.mockResolvedValue(BASE_HARDWARE) mocked.getLocalCatalog.mockResolvedValue({ models: [FITTING_MODEL, SPILLED_MODEL, REFUSED_MODEL] }) mocked.getLocalModelsJobs.mockResolvedValue({ jobs: [] }) $localRuntimeJobs.set([]) }) afterEach(() => { cleanup() vi.clearAllMocks() }) describe('LocalModelsSettings', () => { it('offers the runtime install with a plain-language explanation', async () => { await renderFullPane() expect(await screen.findByText('Install the local runtime')).toBeTruthy() expect(screen.getByText(/runs? entirely on this machine/i)).toBeTruthy() expect(screen.getByRole('button', { name: /install runtime/i })).toBeTruthy() }) it('shows every catalog model with fit pills; unaffordable ones stay visible with the reason', async () => { await renderFullPane() expect(await screen.findByText('Qwen3.6 27B')).toBeTruthy() // The fitting model reads as pills, not prose: green memory pill + // green full-context pill (start_window == native, resident on GPU). expect(screen.getByText('Fits your GPU')).toBeTruthy() expect(screen.getByText('Full 256K context').className).toContain('emerald') // The refused model is NOT hidden (discoverability rule): red memory // pill, plus the ceiling it would have had. expect(screen.getByText('Huge Model')).toBeTruthy() expect(screen.getByText('Too big for this machine')).toBeTruthy() // The spilled model reads amber + ONE quiet ceiling pill — the same // 'Up to' shape the refused row wears; no start/grow pair. expect(screen.getByText('Spilled Model')).toBeTruthy() expect(screen.getByText('Uses system RAM')).toBeTruthy() expect(screen.getAllByText('Up to 256K context').length).toBe(2) expect(screen.queryByText(/Starts at/)).toBeNull() // Its download button is disabled; the fitting model's is enabled once // the runtime exists (here runtime_installed=false, so both disabled — // asserted separately below). const buttons = screen.getAllByRole('button', { name: /download · 17\.6 GB/i }) expect(buttons.every(b => (b as HTMLButtonElement).disabled)).toBe(true) }) it('orders the catalog by fit: resident first, then spilled, then too-big', async () => { // Scrambled input — the pane, not the backend, owns display order. mocked.getLocalCatalog.mockResolvedValue({ models: [REFUSED_MODEL, SPILLED_MODEL, FITTING_MODEL] }) await renderFullPane() await screen.findByText('Qwen3.6 27B') // The matched element is the row-title span; the recommended row's // includes its nested pill copy — strip it before comparing order. const names = screen .getAllByText(/^(Qwen3\.6 27B|Spilled Model|Huge Model)$/) .map(el => el.textContent?.replace('Recommended', '')) expect(names).toEqual(['Qwen3.6 27B', 'Spilled Model', 'Huge Model']) }) it('never greens the full-context pill on a system-RAM model', async () => { // Full native window, but earned by spilling into system RAM: the // pill must not wear the green that would recommend exactly the // wrong model. const spilledFull: LocalCatalogModel = { ...FITTING_MODEL, id: 'Spilled-Full', display_name: 'Spilled Full', recommended: false, spilled: true, fit_summary: 'runs its full 256K context, partly from system RAM' } mocked.getLocalCatalog.mockResolvedValue({ models: [spilledFull] }) await renderFullPane() await screen.findByText('Spilled Full') expect(screen.getByText('Full 256K context').className).not.toContain('emerald') }) it('explains the Recommended pick on hover', async () => { // The tooltip is the resolver's own reason, and it must actually OPEN: // Tip works by asChild-cloning hover handlers onto the pill, so a Pill // that swallows its rest props kills the tooltip silently (the pill // still renders, nothing appears on hover). mocked.getLocalCatalog.mockResolvedValue({ models: [{ ...FITTING_MODEL, recommended_reason: 'speed-gated-quality' }] }) await renderFullPane() await screen.findByText('Qwen3.6 27B') fireEvent.pointerMove(screen.getByText('Recommended')) fireEvent.pointerEnter(screen.getByText('Recommended')) await waitFor(() => expect(screen.getAllByText(/would respond too slowly on its memory bandwidth/).length).toBeGreaterThan(0) ) }) it('enables downloads only once the runtime is installed', async () => { mocked.getLocalModelsStatus.mockResolvedValue({ ...BASE_STATUS, runtime_installed: true, runtime_backend: 'cuda' }) await renderFullPane() await screen.findByText('Qwen3.6 27B') const [fittingButton] = screen.getAllByRole('button', { name: /download · 17\.6 GB/i }) expect((fittingButton as HTMLButtonElement).disabled).toBe(false) }) it('shows hardware facts after backfill', async () => { await renderFullPane() expect(await screen.findByText('NVIDIA GeForce RTX 5090')).toBeTruthy() expect(screen.getByText(/32\.0 GB GPU memory/)).toBeTruthy() expect(screen.getByText(/256\.0 GB RAM/)).toBeTruthy() }) it('tracks a download job to completion and refreshes', async () => { mocked.getLocalModelsStatus.mockResolvedValue({ ...BASE_STATUS, runtime_installed: true, runtime_backend: 'cuda' }) mocked.downloadLocalModel.mockResolvedValue({ job_id: 'j1' }) const running: LocalRuntimeJob = { job_id: 'j1', kind: 'model-download', target: 'Qwen3.6 27B', model_id: FITTING_MODEL.id, status: 'running', phase: 'downloading', detail: 'Qwen3.6 27B — 17.6 GB', total_bytes: 100, done_bytes: 40, percent: 40, error: null } mocked.getLocalModelsJobs .mockResolvedValueOnce({ jobs: [running] }) .mockResolvedValue({ jobs: [{ ...running, status: 'done', phase: 'done', done_bytes: 100, percent: 100 }] }) await renderFullPane() await screen.findByText('Qwen3.6 27B') const [download] = screen.getAllByRole('button', { name: /download · 17\.6 GB/i }) download.click() // The app-level watcher follows the job; when it settles the pane // refreshes (status + catalog re-fetched). await waitFor(() => { expect(mocked.getLocalModelsJobs).toHaveBeenCalled() expect(mocked.getLocalModelsStatus.mock.calls.length).toBeGreaterThanOrEqual(2) }) }) it('renders progress for a download discovered from the store (survives pane remount)', async () => { mocked.getLocalModelsStatus.mockResolvedValue({ ...BASE_STATUS, runtime_installed: true, runtime_backend: 'cuda' }) // A running job already in the app-level store — as after closing and // reopening the pane mid-download. $localRuntimeJobs.set([ { job_id: 'j9', kind: 'model-download', target: 'Qwen3.6 27B', model_id: FITTING_MODEL.id, status: 'running', phase: 'downloading', detail: '', total_bytes: 100, done_bytes: 62, percent: 62, error: null } ]) await renderFullPane() await screen.findByText('Qwen3.6 27B') // The fitting row shows byte progress; the remaining download // buttons belong to the other rows (spilled + refused). expect(screen.getAllByText(/0\.0 GB of 0\.0 GB|of/).length).toBeGreaterThan(0) const remaining = screen.queryAllByRole('button', { name: /download · 17\.6 GB/i }) expect(remaining.length).toBe(2) expect(remaining.some(b => (b as HTMLButtonElement).disabled)).toBe(true) }) it('surfaces a failed download with the backend message', async () => { mocked.getLocalModelsStatus.mockResolvedValue({ ...BASE_STATUS, runtime_installed: true, runtime_backend: 'cuda' }) $localRuntimeJobs.set([ { job_id: 'j2', kind: 'model-download', target: 'Qwen3.6 27B', model_id: FITTING_MODEL.id, status: 'error', phase: 'verifying', detail: '', total_bytes: 100, done_bytes: 100, error: 'Downloaded file failed its integrity check and was removed — try again' } ]) await renderFullPane() await screen.findByText('Qwen3.6 27B') expect(await screen.findByText(/integrity check/)).toBeTruthy() }) }) describe('quickstart', () => { it('leads with one button on a fresh machine and fires the quickstart job', async () => { mocked.quickstartLocalModels.mockResolvedValue({ display_name: 'Qwen3.6 27B', download_bytes: FITTING_MODEL.size_bytes, job_id: 'q1', model_id: 'qwen3.6-27b', needs_download: true, needs_runtime: true }) renderPane() // The card names the recommended model and the one-click action; the // runtime/model machinery is NOT on screen. expect(await screen.findByRole('button', { name: /set up for me/i })).toBeTruthy() expect(screen.queryByText('Install the local runtime')).toBeNull() fireEvent.click(screen.getByRole('button', { name: /set up for me/i })) await waitFor(() => { expect(mocked.quickstartLocalModels).toHaveBeenCalled() }) }) it('pins the quickstart progress view while the job runs', async () => { $localRuntimeJobs.set([ { job_id: 'q1', kind: 'quickstart', target: 'Qwen3.6 27B', model_id: 'qwen3.6-27b', status: 'running', phase: 'downloading', detail: 'Qwen3.6 27B — 17.6 GB', total_bytes: 100, done_bytes: 30, percent: 30, error: null } ]) renderPane() expect(await screen.findByText('Qwen3.6 27B — 17.6 GB')).toBeTruthy() // One job, one view: no Set up / Configure buttons while it runs. expect(screen.queryByRole('button', { name: /set up for me/i })).toBeNull() }) it('skips the card entirely once a model is staged', async () => { mocked.getLocalModelsStatus.mockResolvedValue({ ...BASE_STATUS, runtime_installed: true, runtime_backend: 'cuda', models: [{ id: 'Qwen3.6-27B-UD-Q4_K_XL', size_bytes: 17 * 2 ** 30, size_label: '17.6 GB' }] }) renderPane() // Straight to the full pane — no quickstart hero for a working setup. expect(await screen.findByText('Qwen3.6 27B')).toBeTruthy() expect(screen.queryByRole('button', { name: /set up for me/i })).toBeNull() }) }) describe('BrowseSection', () => { it('searches HF after a pause and shows fit-priced files on demand', async () => { vi.useFakeTimers() try { vi.mocked(hermes.searchHFModels).mockResolvedValue({ hits: [{ downloads: 872724, gated: false, likes: 47, repo: 'unsloth/Qwen3.8-27B-GGUF', updated: '2026-08-18' }] }) vi.mocked(hermes.listHFRepoFiles).mockResolvedValue({ files: [ { fit: 'fits-gpu', label: 'Q4_K_M', paths: ['Qwen3.8-27B-Q4_K_M.gguf'], total_bytes: 17 * 2 ** 30 }, { fit: 'too-big', label: 'F16', paths: ['Qwen3.8-27B-F16.gguf'], total_bytes: 56 * 2 ** 30 } ] }) render( ) await act(async () => { await vi.runOnlyPendingTimersAsync() }) // Fresh machine leads with the quickstart card — enter the full pane. fireEvent.click(screen.getByRole('button', { name: /configure/i })) const box = screen.getByPlaceholderText(/search models/i) fireEvent.change(box, { target: { value: 'qwen' } }) // Debounce: no call until the pause elapses. expect(hermes.searchHFModels).not.toHaveBeenCalled() await act(async () => { await vi.advanceTimersByTimeAsync(400) }) expect(hermes.searchHFModels).toHaveBeenCalledWith('qwen') expect(screen.getByText('unsloth/Qwen3.8-27B-GGUF')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: /show files/i })) await act(async () => { await vi.runOnlyPendingTimersAsync() }) expect(screen.getByText('Q4_K_M')).toBeTruthy() // Each tile has an explicit download button; the too-big quant's is // disabled, the fitting one is live and starts the download. const q4Btn = screen.getByRole('button', { name: 'Download Q4_K_M' }) const f16Btn = screen.getByRole('button', { name: 'Download F16' }) expect((f16Btn as HTMLButtonElement).disabled).toBe(true) expect((q4Btn as HTMLButtonElement).disabled).toBe(false) vi.mocked(hermes.downloadBrowsedModel).mockResolvedValue({ job_id: 'j1', model_id: 'Qwen3.8-27B-Q4_K_M' }) fireEvent.click(q4Btn) await act(async () => { await vi.runOnlyPendingTimersAsync() }) expect(hermes.downloadBrowsedModel).toHaveBeenCalledWith('unsloth/Qwen3.8-27B-GGUF', ['Qwen3.8-27B-Q4_K_M.gguf']) } finally { vi.useRealTimers() } }) }) describe('added-by-you rows', () => { it('staged models outside the catalog get the full action set', async () => { vi.mocked(hermes.getLocalModelsStatus).mockResolvedValue({ ...BASE_STATUS, loaded_models: { 'Hermes-4.3-36B-Q5_K_M': 'loaded' }, models: [{ id: 'Hermes-4.3-36B-Q5_K_M', size_bytes: 25 * 2 ** 30, size_label: '25.0 GB' }], placement: { 'Hermes-4.3-36B-Q5_K_M': { granted_window_label: '96K', spilled: false, window: 98304, window_label: '96K' } }, server_running: true }) vi.mocked(hermes.getLocalCatalog).mockResolvedValue({ models: [] }) renderPane() await screen.findByText('Hermes-4.3-36B-Q5_K_M') // Full management surface: Use, eject, delete, live placement pill. expect(screen.getByText(/added by you/i)).toBeTruthy() expect(screen.getByRole('button', { name: /use/i })).toBeTruthy() expect(screen.getByText(/96K/)).toBeTruthy() const buttons = screen.getAllByRole('button') expect(buttons.length).toBeGreaterThanOrEqual(3) }) }) describe('quickstart completion navigation', () => { it('lands on a new chat when a quickstart it watched finishes; stale done jobs on mount never navigate', async () => { const routeProbe = vi.fn() function Probe() { const loc = useLocation() routeProbe(loc.pathname) return null } const doneJob: LocalRuntimeJob = { done_bytes: 0, detail: '', error: null, job_id: 'stale-done', kind: 'quickstart', model_id: 'qwen3.8-27b', phase: 'done', status: 'done', target: 'Qwen3.8 27B', total_bytes: null } // A finished quickstart already in history when the pane mounts — // must NOT trigger navigation. $localRuntimeJobs.set([doneJob]) render( ) await act(async () => {}) expect(routeProbe).not.toHaveBeenCalledWith('/') // A quickstart the pane SAW running that then completes -> navigate. const running: LocalRuntimeJob = { ...doneJob, job_id: 'live-run', phase: 'downloading', status: 'running' } await act(async () => { $localRuntimeJobs.set([doneJob, running]) }) await act(async () => { $localRuntimeJobs.set([doneJob, { ...running, phase: 'done', status: 'done' }]) }) expect(routeProbe).toHaveBeenCalledWith('/') }) })