test(desktop): follow main's store and copy changes

- local-models-surfaces: the profile mock carries the roster + key helper that
  settings-scope now reads
- model-picker: one QueryClient per test so seeding lands on the client the
  picker mounts against; the jobs API answers with the seeded jobs so the
  on-mount refetch cannot wipe them
- boot-failure-overlay: read the bridge-unavailable copy from the en dictionary
- use-status-snapshot: dismiss departs through the card stack; skip motion in
  this file so the exit never straddles the fake/real timer swap
This commit is contained in:
ethernet
2026-09-20 00:15:39 -04:00
parent 1068ae3a50
commit 42e46cde51
4 changed files with 35 additions and 9 deletions

View File

@@ -1,4 +1,5 @@
import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
import { MotionGlobalConfig } from 'motion/react'
import { createElement, type ReactElement, type ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -24,6 +25,11 @@ async function flushAsync() {
})
}
// This file is about status RPC cadence, not card choreography: the notification
// stack's exit animation would otherwise straddle the fake→real timer swap between
// locales and leave a departing card in the next test's DOM.
MotionGlobalConfig.skipAnimations = true
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
@@ -71,7 +77,11 @@ describe('useStatusSnapshot', () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(screen.getByText(warning)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: copy.notifications.dismiss }))
// Dismiss departs through the card stack (store row on a microtask, then the exit).
await act(async (): Promise<void> => {
fireEvent.click(screen.getByRole('button', { name: copy.notifications.dismiss }))
await vi.advanceTimersByTimeAsync(100)
})
expect(screen.queryByText(warning)).toBeNull()
await act(async (): Promise<void> => {
await vi.advanceTimersByTimeAsync(60_000)
@@ -90,6 +100,9 @@ describe('useStatusSnapshot', () => {
vi.mocked(getStatus).mockResolvedValue({ shared_profile_warning: false } as never)
rerender({ scope: 'local-work' })
await flushAsync()
await act(async (): Promise<void> => {
await vi.advanceTimersByTimeAsync(100)
})
expect(screen.queryByText(warning)).toBeNull()
}
)

View File

@@ -1,6 +1,7 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { en } from '@/i18n/en'
import { $desktopBoot } from '@/store/boot'
import { $notifications } from '@/store/notifications'
import { $desktopOnboarding } from '@/store/onboarding'
@@ -330,7 +331,7 @@ describe('BootFailureOverlay', () => {
fireEvent.click(await screen.findByRole('button', { name: /repair install/i }))
const message = failure === 'thrown' ? 'installer permission denied'
: failure === 'refused' ? 'bundled-immutable' : 'Desktop IPC bridge is unavailable.'
: failure === 'refused' ? 'bundled-immutable' : en.boot.errors.ipcBridgeUnavailable
await waitFor(() => expect($notifications.get()).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'error', message })

View File

@@ -13,7 +13,12 @@ import type { LocalRuntimeJob } from '@/types/hermes'
import { ModelPickerDialog } from './model-picker'
// The jobs query refetches on mount and would replace a seeded cache entry with
// whatever the backend answers; answering with the seeded jobs keeps the two equal.
const seededJobs: { current: readonly LocalRuntimeJob[] } = vi.hoisted(() => ({ current: [] }))
vi.mock('@/hermes', () => ({
getLocalModelsJobs: vi.fn(async () => ({ jobs: [...seededJobs.current] })),
getLocalModelsStatus: vi.fn().mockResolvedValue({ loading: {} })
}))
vi.mock('@/lib/model-options', async importOriginal => ({
@@ -60,18 +65,18 @@ const DOWNLOAD_JOB: LocalRuntimeJob = {
error: null
}
let seedClient: QueryClient | null = null
// One client per test, created in beforeEach: the tests seed jobs BEFORE
// rendering, so the picker must mount against the client that was seeded.
let client: QueryClient = new QueryClient()
function setRuntimeJobs(jobs: readonly LocalRuntimeJob[]): void {
// Banana's store keeps jobs in react-query, not the old atom: seed the cache
// The jobs store keeps jobs in react-query, not an atom: seed the cache
// directly the way production populates it (localModelsKey(owner, 'jobs')).
seedClient?.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), jobs)
seededJobs.current = jobs
client.setQueryData(localModelsKey(localModelsOwner(), 'jobs'), jobs)
}
function renderPicker(ui?: Partial<Parameters<typeof ModelPickerDialog>[0]>) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
seedClient = client
const element: ReactElement = (
<QueryClientProvider client={client}>
<I18nProvider>
@@ -91,6 +96,7 @@ function renderPicker(ui?: Partial<Parameters<typeof ModelPickerDialog>[0]>) {
}
beforeEach(() => {
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
vi.mocked(requestModelOptions).mockResolvedValue(OPTIONS)
setRuntimeJobs([])
// These suites exercise the local-models rows, which ship behind --local.

View File

@@ -15,7 +15,13 @@ vi.mock('@/hermes', async (): Promise<object> => ({
vi.mock('@/store/profile', async (): Promise<object> => {
const { atom } = await import('nanostores')
return { $activeGatewayProfile: atom<string>('work') }
// settings-scope compares the selected profile against the roster's default; 'work' IS the
// default here so the settings surfaces render without the non-default warning.
return {
$activeGatewayProfile: atom<string>('work'),
$profiles: atom([{ name: 'work', is_default: true }]),
normalizeProfileKey: (name: string | null | undefined): string => (name ?? '').trim() || 'default'
}
})
vi.mock('@/store/session', async (): Promise<object> => {
const { atom } = await import('nanostores')