test(desktop): fix React act() warnings across all desktop test files

Add vitest.setup.ts with IS_REACT_ACT_ENVIRONMENT=true + auto-cleanup,
and wrap render()/fireEvent() calls in act() across 8 test files:

- provider-config-panel.test.tsx: wrap renderPanel + fireEvent in act
- providers-settings.test.tsx: wrap renderProvidersSettings + fireEvent
in act
- use-prompt-actions/index.test.tsx: add actRender helper, wrap all 38
render
  calls, wrap Harness handle methods (submitText/cancelRun/steerPrompt/
  restoreToMessage) in act at the onReady callback level
- attachments.test.tsx: make renderWithI18n async + wrap in act
- skills/index.test.tsx: make renderSkills async + wrap fireEvent in act
- messaging/index.test.tsx: make renderMessaging async + wrap fireEvent
in act
- gateway-connecting-overlay.test.tsx: wrap all render/rerender in act
- preview-pane.test.tsx: wrap render calls in act, make tests async

Reduces act warnings from 44 to 4 (remaining are fake-timer + pre-render
store mutation edge cases). All 146 test files / 1180 tests still pass.
This commit is contained in:
ethernet 2026-07-09 14:13:32 -04:00
parent 999d63b517
commit ef61436967
10 changed files with 236 additions and 149 deletions

View file

@ -1,4 +1,4 @@
import { cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { I18nProvider } from '@/i18n/context'
@ -10,12 +10,17 @@ function makeAttachment(id: string, label = 'test.pdf'): ComposerAttachment {
return { id, kind: 'file', label }
}
function renderWithI18n(ui: React.ReactNode) {
return render(
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
{ui}
</I18nProvider>
)
async function renderWithI18n(ui: React.ReactNode) {
let result: ReturnType<typeof render>
await act(async () => {
result = render(
<I18nProvider configClient={{ getConfig: async () => ({}), saveConfig: async () => ({ ok: true }) }}>
{ui}
</I18nProvider>
)
})
return result!
}
describe('AttachmentList', () => {
@ -23,22 +28,22 @@ describe('AttachmentList', () => {
cleanup()
})
it('renders valid attachments', () => {
it('renders valid attachments', async () => {
const attachments = [makeAttachment('a', 'doc.pdf'), makeAttachment('b', 'img.png')]
renderWithI18n(<AttachmentList attachments={attachments} />)
await renderWithI18n(<AttachmentList attachments={attachments} />)
expect(screen.getByText('doc.pdf')).toBeDefined()
expect(screen.getByText('img.png')).toBeDefined()
})
it('renders empty list without error', () => {
const { container } = renderWithI18n(<AttachmentList attachments={[]} />)
it('renders empty list without error', async () => {
const { container } = await renderWithI18n(<AttachmentList attachments={[]} />)
const attachmentList = container.querySelector('[data-slot="composer-attachments"]')
expect(attachmentList).toBeDefined()
})
it('does not crash when attachments array contains undefined entries', () => {
it('does not crash when attachments array contains undefined entries', async () => {
// Repro: session switch can leave stale/undefined entries in the
// attachments array, causing a TypeError at attachment.refText.
const attachments = [
@ -47,21 +52,17 @@ describe('AttachmentList', () => {
makeAttachment('b', 'also-good.png')
]
expect(() => {
renderWithI18n(<AttachmentList attachments={attachments} />)
}).not.toThrow()
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
// Only valid attachments should render
expect(screen.getByText('good.pdf')).toBeDefined()
expect(screen.getByText('also-good.png')).toBeDefined()
})
it('does not crash when attachments array contains null entries', () => {
it('does not crash when attachments array contains null entries', async () => {
const attachments = [null as unknown as ComposerAttachment, makeAttachment('a', 'valid.txt')]
expect(() => {
renderWithI18n(<AttachmentList attachments={attachments} />)
}).not.toThrow()
await expect(renderWithI18n(<AttachmentList attachments={attachments} />)).resolves.toBeTruthy()
expect(screen.getByText('valid.txt')).toBeDefined()
})

View file

@ -19,7 +19,7 @@ describe('PreviewPane console state', () => {
vi.unstubAllGlobals()
})
it('does not watch backend-only remote filesystem previews locally', () => {
it('does not watch backend-only remote filesystem previews locally', async () => {
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
const onPreviewFileChanged = vi.fn(() => vi.fn())
$connection.set({ mode: 'remote' } as never)
@ -31,38 +31,43 @@ describe('PreviewPane console state', () => {
}
})
render(
<PreviewPane
setTitlebarToolGroup={vi.fn()}
target={{
kind: 'file',
label: 'file.txt',
path: '/remote/file.txt',
previewKind: 'text',
source: '/remote/file.txt',
url: 'file:///remote/file.txt'
}}
/>
)
await act(async () => {
render(
<PreviewPane
setTitlebarToolGroup={vi.fn()}
target={{
kind: 'file',
label: 'file.txt',
path: '/remote/file.txt',
previewKind: 'text',
source: '/remote/file.txt',
url: 'file:///remote/file.txt'
}}
/>
)
})
expect(watchPreviewFile).not.toHaveBeenCalled()
expect(onPreviewFileChanged).not.toHaveBeenCalled()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {
it('does not rebuild the pane titlebar group for streamed console logs', async () => {
const setTitlebarToolGroup = vi.fn()
const rendered = render(
<PreviewPane
setTitlebarToolGroup={setTitlebarToolGroup}
target={{
kind: 'url',
label: 'Preview',
source: 'http://localhost:5174',
url: 'http://localhost:5174'
}}
/>
)
let rendered!: ReturnType<typeof render>
await act(async () => {
rendered = render(
<PreviewPane
setTitlebarToolGroup={setTitlebarToolGroup}
target={{
kind: 'url',
label: 'Preview',
source: 'http://localhost:5174',
url: 'http://localhost:5174'
}}
/>
)
})
const initialCalls = setTitlebarToolGroup.mock.calls.length
const webview = rendered.container.querySelector('webview')

View file

@ -1,5 +1,5 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -53,12 +53,16 @@ afterEach(() => {
async function renderMessaging() {
const { MessagingView } = await import('./index')
let result: ReturnType<typeof render>
await act(async () => {
result = render(
<MemoryRouter>
<MessagingView />
</MemoryRouter>
)
})
return render(
<MemoryRouter>
<MessagingView />
</MemoryRouter>
)
return result!
}
describe('MessagingView setup-guide link', () => {
@ -82,7 +86,9 @@ describe('MessagingView setup-guide link', () => {
await renderMessaging()
const link = await screen.findByText('Open setup guide')
fireEvent.click(link)
await act(async () => {
fireEvent.click(link)
})
await waitFor(() => expect(openExternalLink).toHaveBeenCalledWith(docsUrl))
})

View file

@ -1,4 +1,4 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -43,6 +43,18 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
}
}
// Wrap render() in act() so the Harness's useEffect (onReady callback +
// internal state from usePromptActions) flushes synchronously instead of
// spilling async state updates outside act().
async function actRender(ui: React.ReactElement) {
let result: ReturnType<typeof render>
await act(async () => {
result = render(ui)
})
return result!
}
interface HarnessHandle {
cancelRun: () => Promise<void>
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
@ -125,10 +137,14 @@ function Harness({
useEffect(() => {
onReady({
cancelRun: actions.cancelRun,
restoreToMessage: actions.restoreToMessage,
steerPrompt: actions.steerPrompt,
submitText: actions.submitText
cancelRun: (...args: Parameters<typeof actions.cancelRun>) =>
act(async () => actions.cancelRun(...args)) as Promise<void>,
restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) =>
act(async () => actions.restoreToMessage(...args)) as Promise<void>,
steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) =>
act(async () => actions.steerPrompt(...args)) as Promise<boolean>,
submitText: (...args: Parameters<typeof actions.submitText>) =>
act(async () => actions.submitText(...args)) as Promise<boolean>
})
}, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady])
@ -153,7 +169,9 @@ describe('usePromptActions /title', () => {
)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
)
await handle!.submitText('/title New title')
@ -177,7 +195,9 @@ describe('usePromptActions /title', () => {
)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
)
await handle!.submitText('/title Fresh chat')
@ -195,7 +215,9 @@ describe('usePromptActions /title', () => {
const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never)
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
)
await handle!.submitText('/title')
@ -215,7 +237,9 @@ describe('usePromptActions /title', () => {
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />
)
await handle!.submitText('/title way too long title')
@ -254,7 +278,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
@ -304,7 +328,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
@ -342,7 +366,7 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -372,7 +396,7 @@ describe('usePromptActions desktop slash pickers', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@ -392,7 +416,7 @@ describe('usePromptActions desktop slash pickers', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
openMemoryGraph={openMemoryGraph}
@ -425,7 +449,7 @@ describe('usePromptActions desktop slash pickers', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -455,7 +479,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
@ -488,7 +512,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
busyRef={busyRef}
onReady={h => (handle = h)}
@ -530,7 +554,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@ -574,7 +598,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
@ -596,7 +620,7 @@ describe('usePromptActions submit / queue drain semantics', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
busyRef={busyRef}
onReady={h => (handle = h)}
@ -622,7 +646,7 @@ describe('usePromptActions steerPrompt', () => {
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -641,7 +665,7 @@ describe('usePromptActions steerPrompt', () => {
const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -654,7 +678,7 @@ describe('usePromptActions steerPrompt', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -665,7 +689,7 @@ describe('usePromptActions steerPrompt', () => {
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -697,7 +721,7 @@ describe('usePromptActions restoreToMessage', () => {
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
@ -732,7 +756,7 @@ describe('usePromptActions restoreToMessage', () => {
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
@ -765,7 +789,7 @@ describe('usePromptActions restoreToMessage', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@ -793,7 +817,7 @@ describe('usePromptActions restoreToMessage', () => {
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -808,7 +832,7 @@ describe('usePromptActions restoreToMessage', () => {
let lastState: Record<string, unknown> = {}
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => (lastState = state)}
@ -882,7 +906,7 @@ describe('usePromptActions file attachment sync', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -936,7 +960,7 @@ describe('usePromptActions file attachment sync', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -965,7 +989,7 @@ describe('usePromptActions file attachment sync', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -1025,7 +1049,7 @@ describe('usePromptActions eager-upload races', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await waitFor(() => expect(handle).not.toBeNull())
@ -1083,7 +1107,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@ -1126,7 +1150,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@ -1159,7 +1183,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
@ -1189,7 +1213,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@ -1233,7 +1257,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
@ -1273,7 +1297,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
activeSessionId={null}
createBackendSessionForSend={createBackendSessionForSend}
@ -1315,7 +1339,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
})
let handle: HarnessHandle | null = null
render(
await actRender(
<Harness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
@ -1592,7 +1616,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
{ id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' }
])
render(
await actRender(
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -1622,7 +1646,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
$composerAttachments.set([{ id: 'file:x', kind: 'file', label: 'x.pdf', path: '/abs/x.pdf' }])
render(
await actRender(
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
@ -1646,7 +1670,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => {
}
])
render(
await actRender(
<Harness onReady={() => undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)

View file

@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MemoryProviderConfig } from '@/types/hermes'
@ -97,7 +97,12 @@ afterEach(() => {
async function renderPanel(provider = 'hindsight') {
const { ProviderConfigPanel } = await import('./provider-config-panel')
return render(<ProviderConfigPanel provider={provider} />)
let result: ReturnType<typeof render>
await act(async () => {
result = render(<ProviderConfigPanel provider={provider} />)
})
return result!
}
describe('ProviderConfigPanel', () => {
@ -115,9 +120,13 @@ describe('ProviderConfigPanel', () => {
await renderPanel()
expect(await screen.findByLabelText('API URL')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
})
expect(screen.queryByLabelText('API URL')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
})
expect(await screen.findByLabelText('API URL')).toBeTruthy()
})
@ -125,9 +134,11 @@ describe('ProviderConfigPanel', () => {
await renderPanel()
const apiUrl = await screen.findByLabelText('API URL')
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await act(async () => {
fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
})
await waitFor(() =>
expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', {

View file

@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -73,8 +73,12 @@ afterEach(() => {
async function renderProvidersSettings() {
const { ProvidersSettings } = await import('./providers-settings')
let result: ReturnType<typeof render>
await act(async () => {
result = render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
})
return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
return result!
}
describe('ProvidersSettings', () => {
@ -82,7 +86,9 @@ describe('ProvidersSettings', () => {
await renderProvidersSettings()
const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' })
fireEvent.click(remove)
await act(async () => {
fireEvent.click(remove)
})
await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous'))
expect(listOAuthProviders).toHaveBeenCalledTimes(2)
@ -91,7 +97,9 @@ describe('ProvidersSettings', () => {
it('keeps provider selection separate from account removal', async () => {
await renderProvidersSettings()
fireEvent.click(await screen.findByText('Nous Portal'))
await act(async () => {
fireEvent.click(await screen.findByText('Nous Portal'))
})
expect(startManualProviderOAuth).toHaveBeenCalledWith('nous')
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
@ -132,7 +140,9 @@ describe('ProvidersSettings', () => {
listOAuthProviders.mockResolvedValue({ providers: [] })
const { ProvidersSettings } = await import('./providers-settings')
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
await act(async () => {
render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
})
expect(await screen.findByText('WidgetAI')).toBeTruthy()
})
@ -158,14 +168,18 @@ describe('ProvidersSettings', () => {
// Typing narrows the list to matching providers only.
const search = screen.getByPlaceholderText('Search providers…')
fireEvent.change(search, { target: { value: 'mid' } })
await act(async () => {
fireEvent.change(search, { target: { value: 'mid' } })
})
await waitFor(() => expect(screen.queryByText('Acme')).toBeNull())
expect(screen.getByText('Middle')).toBeTruthy()
expect(screen.queryByText('Zebra')).toBeNull()
// A non-matching query shows the empty-state copy.
fireEvent.change(search, { target: { value: 'nonesuch-xyz' } })
await act(async () => {
fireEvent.change(search, { target: { value: 'nonesuch-xyz' } })
})
expect(await screen.findByText('No providers match your search.')).toBeTruthy()
})
})

View file

@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@ -48,9 +48,11 @@ function toolset(overrides: Record<string, unknown> = {}) {
}
}
function renderSkills() {
return import('./index').then(({ SkillsView }) =>
render(
async function renderSkills() {
const { SkillsView } = await import('./index')
let result: ReturnType<typeof render>
await act(async () => {
result = render(
// SkillsView reads skills/toolsets via useQuery, so it needs a provider.
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
@ -58,7 +60,9 @@ function renderSkills() {
</MemoryRouter>
</QueryClientProvider>
)
)
})
return result!
}
beforeEach(() => {
@ -83,7 +87,9 @@ describe('SkillsView toolset management', () => {
const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
expect(sw.getAttribute('aria-checked')).toBe('true')
fireEvent.click(sw)
await act(async () => {
fireEvent.click(sw)
})
await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false))
})

View file

@ -1,4 +1,4 @@
import { cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $desktopBoot } from '@/store/boot'
@ -61,7 +61,7 @@ const isRecoveryShown = () =>
Boolean(screen.queryByText(/use local gateway/i) || screen.queryByText(/retry/i) || screen.queryByText(/sign in/i))
describe('connecting overlay vs recovery surface', () => {
it('hard initial-boot failure surfaces the recovery overlay (the working path)', () => {
it('hard initial-boot failure surfaces the recovery overlay (the working path)', async () => {
// failDesktopBoot() ran: error set, gateway never opened.
$desktopBoot.set({
...$desktopBoot.get(),
@ -71,28 +71,35 @@ describe('connecting overlay vs recovery surface', () => {
})
setGatewayState('error')
render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
await act(async () => {
render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
})
expect(isRecoveryShown()).toBe(true)
// Connecting overlay bows out when boot.error is set.
expect(isConnectingShown()).toBe(false)
})
it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', () => {
it('post-boot socket drops do not re-cover the app with the initial CONNECTING overlay', async () => {
// 1. Initial boot succeeded: gateway opened, boot completed (no error).
setGatewayState('open')
const { rerender } = render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
let rerender!: (ui: React.ReactElement) => void
await act(async () => {
const result = render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
rerender = result.rerender
})
expect(isConnectingShown()).toBe(false)
@ -100,12 +107,14 @@ describe('connecting overlay vs recovery surface', () => {
// bootCompleted is true, so useGatewayBoot routes this through
// scheduleReconnect() — boot.error stays NULL.
setGatewayState('closed')
rerender(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
await act(async () => {
rerender!(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
})
// The initial-boot connecting overlay stays out of the way, so settings and
// the composer remain reachable during the reconnect loop.
@ -116,12 +125,14 @@ describe('connecting overlay vs recovery surface', () => {
// → error → closed. Until the escalation path sets boot.error, the app
// remains usable instead of modal-blocked.
setGatewayState('error')
rerender(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
await act(async () => {
rerender!(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
})
expect($desktopBoot.get().error).toBeNull()
expect(isConnectingShown()).toBe(false)
expect(isRecoveryShown()).toBe(false)
@ -156,7 +167,7 @@ describe('connecting overlay vs recovery surface', () => {
expect(isRecoveryShown()).toBe(false)
})
it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', () => {
it('FIX: once the prolonged reconnect raises a recoverable boot error, the recovery overlay takes over', async () => {
// Mirrors what useGatewayBoot.scheduleReconnect() now does after ~45s of
// failed post-boot reconnects: it calls failDesktopBoot(), flipping the UI
// from the dead-end CONNECTING overlay to the recovery surface.
@ -168,12 +179,14 @@ describe('connecting overlay vs recovery surface', () => {
visible: true
})
render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
await act(async () => {
render(
<>
<GatewayConnectingOverlay />
<BootFailureOverlay />
</>
)
})
// Escape hatch is now reachable; the connecting overlay bows out.
expect(isRecoveryShown()).toBe(true)

View file

@ -11,6 +11,7 @@ export default defineConfig({
},
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
globals: true
},

View file

@ -0,0 +1,6 @@
import '@testing-library/react'
// React 19 + Testing Library 16: opt into the act environment so render(),
// fireEvent(), and findBy* queries automatically flush state updates without
// spurious "not wrapped in act(...)" warnings.
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true