mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
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:
parent
999d63b517
commit
ef61436967
10 changed files with 236 additions and 149 deletions
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export default defineConfig({
|
|||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
globals: true
|
||||
},
|
||||
|
|
|
|||
6
apps/desktop/vitest.setup.ts
Normal file
6
apps/desktop/vitest.setup.ts
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue