diff --git a/apps/desktop/src/app/chat/composer/attachments.test.tsx b/apps/desktop/src/app/chat/composer/attachments.test.tsx index 8bde5b944592..52e8f6bf98e8 100644 --- a/apps/desktop/src/app/chat/composer/attachments.test.tsx +++ b/apps/desktop/src/app/chat/composer/attachments.test.tsx @@ -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( - ({}), saveConfig: async () => ({ ok: true }) }}> - {ui} - - ) +async function renderWithI18n(ui: React.ReactNode) { + let result: ReturnType + await act(async () => { + result = render( + ({}), saveConfig: async () => ({ ok: true }) }}> + {ui} + + ) + }) + + 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() + await renderWithI18n() expect(screen.getByText('doc.pdf')).toBeDefined() expect(screen.getByText('img.png')).toBeDefined() }) - it('renders empty list without error', () => { - const { container } = renderWithI18n() + it('renders empty list without error', async () => { + const { container } = await renderWithI18n() 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() - }).not.toThrow() + await expect(renderWithI18n()).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() - }).not.toThrow() + await expect(renderWithI18n()).resolves.toBeTruthy() expect(screen.getByText('valid.txt')).toBeDefined() }) diff --git a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx index 51e5539bac9e..650900a42e9d 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-pane.test.tsx @@ -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( - - ) + await act(async () => { + render( + + ) + }) 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( - - ) + let rendered!: ReturnType + await act(async () => { + rendered = render( + + ) + }) const initialCalls = setTitlebarToolGroup.mock.calls.length const webview = rendered.container.querySelector('webview') diff --git a/apps/desktop/src/app/messaging/index.test.tsx b/apps/desktop/src/app/messaging/index.test.tsx index a7d9273c0c90..b078a2043b1d 100644 --- a/apps/desktop/src/app/messaging/index.test.tsx +++ b/apps/desktop/src/app/messaging/index.test.tsx @@ -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 + await act(async () => { + result = render( + + + + ) + }) - return render( - - - - ) + 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)) }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index 66a216d14ae7..3e725b1480d3 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -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 { } } +// 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 + await act(async () => { + result = render(ui) + }) + + return result! +} + interface HarnessHandle { cancelRun: () => Promise restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise @@ -125,10 +137,14 @@ function Harness({ useEffect(() => { onReady({ - cancelRun: actions.cancelRun, - restoreToMessage: actions.restoreToMessage, - steerPrompt: actions.steerPrompt, - submitText: actions.submitText + cancelRun: (...args: Parameters) => + act(async () => actions.cancelRun(...args)) as Promise, + restoreToMessage: (...args: Parameters) => + act(async () => actions.restoreToMessage(...args)) as Promise, + steerPrompt: (...args: Parameters) => + act(async () => actions.steerPrompt(...args)) as Promise, + submitText: (...args: Parameters) => + act(async () => actions.submitText(...args)) as Promise }) }, [actions.cancelRun, actions.restoreToMessage, actions.steerPrompt, actions.submitText, onReady]) @@ -153,7 +169,9 @@ describe('usePromptActions /title', () => { ) let handle: HarnessHandle | null = null - render( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + (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( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + (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( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} /> + ) await handle!.submitText('/title') @@ -215,7 +237,9 @@ describe('usePromptActions /title', () => { }) let handle: HarnessHandle | null = null - render( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + await actRender( + (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( (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( (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( (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( (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( (handle = h)} openMemoryGraph={openMemoryGraph} @@ -425,7 +449,7 @@ describe('usePromptActions desktop slash pickers', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (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( (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( (handle = h)} @@ -530,7 +554,7 @@ describe('usePromptActions submit / queue drain semantics', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (handle = h)} refreshSessions={async () => undefined} @@ -574,7 +598,7 @@ describe('usePromptActions submit / queue drain semantics', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (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( (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( (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( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -654,7 +678,7 @@ describe('usePromptActions steerPrompt', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (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( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -697,7 +721,7 @@ describe('usePromptActions restoreToMessage', () => { let lastState: Record = {} let handle: HarnessHandle | null = null - render( + await actRender( (handle = h)} onSeedState={state => (lastState = state)} @@ -732,7 +756,7 @@ describe('usePromptActions restoreToMessage', () => { let lastState: Record = {} let handle: HarnessHandle | null = null - render( + await actRender( (handle = h)} onSeedState={state => (lastState = state)} @@ -765,7 +789,7 @@ describe('usePromptActions restoreToMessage', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (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( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -808,7 +832,7 @@ describe('usePromptActions restoreToMessage', () => { let lastState: Record = {} let handle: HarnessHandle | null = null - render( + await actRender( (handle = h)} onSeedState={state => (lastState = state)} @@ -882,7 +906,7 @@ describe('usePromptActions file attachment sync', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -936,7 +960,7 @@ describe('usePromptActions file attachment sync', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -965,7 +989,7 @@ describe('usePromptActions file attachment sync', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -1025,7 +1049,7 @@ describe('usePromptActions eager-upload races', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (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( (handle = h)} refreshSessions={async () => undefined} @@ -1126,7 +1150,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (handle = h)} refreshSessions={async () => undefined} @@ -1159,7 +1183,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (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( (handle = h)} refreshSessions={async () => undefined} @@ -1233,7 +1257,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( (handle = h)} refreshSessions={async () => undefined} @@ -1273,7 +1297,7 @@ describe('usePromptActions sleep/wake session recovery', () => { }) let handle: HarnessHandle | null = null - render( + await actRender( { }) let handle: HarnessHandle | null = null - render( + await actRender( { { id: 'file:devis', kind: 'file', label: 'DEVIS_signed.pdf', path: '/Users/mahmoud/Downloads/DEVIS_signed.pdf' } ]) - render( + await actRender( 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( undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) @@ -1646,7 +1670,7 @@ describe('usePromptActions eager attachment upload (drop-time)', () => { } ]) - render( + await actRender( undefined} refreshSessions={async () => undefined} requestGateway={requestGateway} /> ) diff --git a/apps/desktop/src/app/settings/provider-config-panel.test.tsx b/apps/desktop/src/app/settings/provider-config-panel.test.tsx index 774d45f40793..fa95f9dcd70a 100644 --- a/apps/desktop/src/app/settings/provider-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/provider-config-panel.test.tsx @@ -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() + let result: ReturnType + await act(async () => { + result = render() + }) + + 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', { diff --git a/apps/desktop/src/app/settings/providers-settings.test.tsx b/apps/desktop/src/app/settings/providers-settings.test.tsx index 8a894e27ab15..ac63be962ae2 100644 --- a/apps/desktop/src/app/settings/providers-settings.test.tsx +++ b/apps/desktop/src/app/settings/providers-settings.test.tsx @@ -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 + await act(async () => { + result = render() + }) - return render() + 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() + await act(async () => { + render() + }) 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() }) }) diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index fe3e39a72c76..f4057a4f2b88 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -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 = {}) { } } -function renderSkills() { - return import('./index').then(({ SkillsView }) => - render( +async function renderSkills() { + const { SkillsView } = await import('./index') + let result: ReturnType + await act(async () => { + result = render( // SkillsView reads skills/toolsets via useQuery, so it needs a provider. @@ -58,7 +60,9 @@ function renderSkills() { ) - ) + }) + + 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)) }) diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx index 0225e8cb22d0..1a4fcca270a7 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx @@ -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( - <> - - - - ) + await act(async () => { + render( + <> + + + + ) + }) 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( - <> - - - - ) + let rerender!: (ui: React.ReactElement) => void + await act(async () => { + const result = render( + <> + + + + ) + + 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( - <> - - - - ) + await act(async () => { + rerender!( + <> + + + + ) + }) // 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( - <> - - - - ) + await act(async () => { + rerender!( + <> + + + + ) + }) 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( - <> - - - - ) + await act(async () => { + render( + <> + + + + ) + }) // Escape hatch is now reachable; the connecting overlay bows out. expect(isRecoveryShown()).toBe(true) diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index 7ec1b7f80949..4bdc3793fa53 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ }, test: { environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], include: ["src/**/*.test.{ts,tsx}"], globals: true }, diff --git a/apps/desktop/vitest.setup.ts b/apps/desktop/vitest.setup.ts new file mode 100644 index 000000000000..a671ac3ae971 --- /dev/null +++ b/apps/desktop/vitest.setup.ts @@ -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