diff --git a/package-lock.json b/package-lock.json index 69fb107bc6..a0e554676c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -598,7 +598,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -622,7 +621,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2428,7 +2426,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2609,7 +2606,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2643,7 +2639,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -3012,7 +3007,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3046,7 +3040,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -3099,7 +3092,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -3815,7 +3807,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4348,7 +4339,6 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4359,7 +4349,6 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -4637,7 +4626,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5405,7 +5393,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5769,7 +5756,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-includes": { "version": "3.1.9", @@ -7015,6 +7003,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -8062,7 +8051,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8652,6 +8640,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8661,6 +8650,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8670,6 +8660,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -8899,6 +8890,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -8917,6 +8909,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8925,13 +8918,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -10148,7 +10143,6 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -13285,7 +13279,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "3.0.0", @@ -13819,7 +13814,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13830,7 +13824,6 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -13864,7 +13857,6 @@ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -15928,7 +15920,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16139,8 +16130,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -16148,7 +16138,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16333,7 +16322,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16495,6 +16483,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16550,7 +16539,6 @@ "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16667,7 +16655,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16681,7 +16668,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17433,7 +17419,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17975,7 +17960,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 3ce23405f3..e4fa0364ac 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { vi, type MockedFunction } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -61,11 +59,13 @@ vi.mock('simple-git', () => ({ }), })); +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); + vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); return { ...mockedOs, - homedir: vi.fn(), + homedir: mockHomedir, }; }); diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts index 8c02168164..8dfe841d74 100644 --- a/packages/cli/src/config/extensions/update.test.ts +++ b/packages/cli/src/config/extensions/update.test.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { vi, type MockedFunction } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -50,13 +48,9 @@ vi.mock('os', async (importOriginal) => { }; }); -vi.mock('../trustedFolders.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isWorkspaceTrusted: vi.fn(), - }; -}); +vi.mock('../trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(), +})); const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); diff --git a/packages/cli/src/test-utils/render.test.tsx b/packages/cli/src/test-utils/render.test.tsx new file mode 100644 index 0000000000..b705c2a5e1 --- /dev/null +++ b/packages/cli/src/test-utils/render.test.tsx @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { useState, useEffect } from 'react'; +import { renderHook } from './render.js'; + +describe('renderHook', () => { + it('should rerender with previous props when called without arguments', async () => { + const useTestHook = ({ value }: { value: number }) => { + const [count, setCount] = useState(0); + useEffect(() => { + setCount((c) => c + 1); + }, [value]); + return { count, value }; + }; + + const { result, rerender } = renderHook(useTestHook, { + initialProps: { value: 1 }, + }); + + expect(result.current.value).toBe(1); + await vi.waitFor(() => expect(result.current.count).toBe(1)); + + // Rerender with new props + rerender({ value: 2 }); + expect(result.current.value).toBe(2); + await vi.waitFor(() => expect(result.current.count).toBe(2)); + + // Rerender without arguments should use previous props (value: 2) + // This would previously crash or pass undefined if not fixed + rerender(); + expect(result.current.value).toBe(2); + // Count should not increase because value didn't change + await vi.waitFor(() => expect(result.current.count).toBe(2)); + }); + + it('should handle initial render without props', () => { + const useTestHook = () => { + const [count, setCount] = useState(0); + return { count, increment: () => setCount((c) => c + 1) }; + }; + + const { result, rerender } = renderHook(useTestHook); + + expect(result.current.count).toBe(0); + + rerender(); + expect(result.current.count).toBe(0); + }); + + it('should update props if undefined is passed explicitly', () => { + const useTestHook = (val: string | undefined) => val; + const { result, rerender } = renderHook(useTestHook, { + initialProps: 'initial', + }); + + expect(result.current).toBe('initial'); + + rerender(undefined); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 3eba2ff964..1eb00406c5 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -6,6 +6,7 @@ import { render } from 'ink-testing-library'; import type React from 'react'; +import { act } from 'react'; import { LoadedSettings, type Settings } from '../config/settings.js'; import { KeypressProvider } from '../ui/contexts/KeypressContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js'; @@ -128,3 +129,59 @@ export const renderWithProviders = ( , ); }; + +export function renderHook( + renderCallback: (props: Props) => Result, + options?: { + initialProps?: Props; + wrapper?: React.ComponentType<{ children: React.ReactNode }>; + }, +): { + result: { current: Result }; + rerender: (props?: Props) => void; + unmount: () => void; +} { + const result = { current: undefined as unknown as Result }; + let currentProps = options?.initialProps as Props; + + function TestComponent({ + renderCallback, + props, + }: { + renderCallback: (props: Props) => Result; + props: Props; + }) { + result.current = renderCallback(props); + return null; + } + + const Wrapper = options?.wrapper || (({ children }) => <>{children}); + + let inkRerender: (tree: React.ReactElement) => void = () => {}; + let unmount: () => void = () => {}; + + act(() => { + const renderResult = render( + + + , + ); + inkRerender = renderResult.rerender; + unmount = renderResult.unmount; + }); + + function rerender(props?: Props) { + if (arguments.length > 0) { + currentProps = props as Props; + } + act(() => { + inkRerender( + + + , + ); + }); + } + + return { result, rerender, unmount }; +} diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 77280be320..588f39653e 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -4,10 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { renderWithProviders } from '../../test-utils/render.js'; -import { waitFor, act } from '@testing-library/react'; +import { act } from 'react'; import { vi } from 'vitest'; import { FolderTrustDialog } from './FolderTrustDialog.js'; import * as processUtils from '../../utils/processUtils.js'; @@ -56,12 +54,12 @@ describe('FolderTrustDialog', () => { stdin.write('\u001b[27u'); // Press kitty escape key }); - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain( 'A folder trust level must be selected to continue. Exiting since escape was pressed.', ); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockedExit).toHaveBeenCalledWith(1); }); expect(onSelect).not.toHaveBeenCalled(); @@ -95,7 +93,7 @@ describe('FolderTrustDialog', () => { stdin.write('r'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockedExit).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx index ff749643ba..27f072c8eb 100644 --- a/packages/cli/src/ui/components/Help.test.tsx +++ b/packages/cli/src/ui/components/Help.test.tsx @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { render } from 'ink-testing-library'; import { describe, it, expect } from 'vitest'; import { Help } from './Help.js'; diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 0080a03b3d..1bcfb5c75f 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -4,9 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - -import { render, cleanup } from '@testing-library/react'; +import { render, cleanup } from 'ink-testing-library'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { DEFAULT_GEMINI_FLASH_LITE_MODEL, @@ -82,12 +80,12 @@ describe('', () => { }); it('renders the title and help text', () => { - const { getByText } = renderComponent(); - expect(getByText('Select Model')).toBeDefined(); - expect(getByText('(Press Esc to close)')).toBeDefined(); - expect( - getByText('> To use a specific Gemini model, use the --model flag.'), - ).toBeDefined(); + const { lastFrame } = renderComponent(); + expect(lastFrame()).toContain('Select Model'); + expect(lastFrame()).toContain('(Press Esc to close)'); + expect(lastFrame()).toContain( + '> To use a specific Gemini model, use the --model flag.', + ); }); it('passes all model options to DescriptiveRadioButtonSelect', () => { diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx index ed2740c580..4cf24614fa 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx @@ -4,16 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - -/// - import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { TrustLevel } from '../../config/trustedFolders.js'; -import { waitFor, act } from '@testing-library/react'; +import { act } from 'react'; import * as processUtils from '../../utils/processUtils.js'; import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js'; @@ -72,7 +68,7 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain('Modify Trust Level'); expect(lastFrame()).toContain('Folder: /test/dir'); expect(lastFrame()).toContain('Current Level: DO_NOT_TRUST'); @@ -94,7 +90,7 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain( 'Note: This folder behaves as a trusted folder because one of the parent folders is trusted.', ); @@ -116,7 +112,7 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain( 'Note: This folder behaves as a trusted folder because the connected IDE workspace is trusted.', ); @@ -128,7 +124,7 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain('Trust this folder (dir)'); expect(lastFrame()).toContain('Trust parent folder (test)'); }); @@ -140,13 +136,13 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); + await vi.waitFor(() => expect(lastFrame()).not.toContain('Loading...')); act(() => { stdin.write('\u001b[27u'); // Kitty escape key }); - await waitFor(() => { + await vi.waitFor(() => { expect(onExit).toHaveBeenCalled(); }); }); @@ -171,11 +167,11 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); + await vi.waitFor(() => expect(lastFrame()).not.toContain('Loading...')); act(() => stdin.write('r')); // Press 'r' to restart - await waitFor(() => { + await vi.waitFor(() => { expect(mockCommitTrustLevelChange).toHaveBeenCalled(); expect(mockRelaunchApp).toHaveBeenCalled(); expect(onExit).toHaveBeenCalled(); @@ -201,11 +197,11 @@ describe('PermissionsModifyTrustDialog', () => { , ); - await waitFor(() => expect(lastFrame()).not.toContain('Loading...')); + await vi.waitFor(() => expect(lastFrame()).not.toContain('Loading...')); act(() => stdin.write('\u001b[27u')); // Press kitty escape key - await waitFor(() => { + await vi.waitFor(() => { expect(mockCommitTrustLevelChange).not.toHaveBeenCalled(); expect(onExit).toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 50d32c1871..f8577e6bb7 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - /** * * @@ -30,7 +28,6 @@ import { LoadedSettings, SettingScope } from '../../config/settings.js'; import { VimModeProvider } from '../contexts/VimModeContext.js'; import { KeypressProvider } from '../contexts/KeypressContext.js'; import { act } from 'react'; -import { waitFor } from '@testing-library/react'; import { saveModifiedSettings, TEST_ONLY } from '../../utils/settingsUtils.js'; import { getSettingsSchema, @@ -408,7 +405,7 @@ describe('SettingsDialog', () => { const { stdin, unmount, lastFrame } = render(component); // Wait for initial render and verify we're on Vim Mode (first setting) - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain('● Vim Mode'); }); @@ -416,7 +413,7 @@ describe('SettingsDialog', () => { act(() => { stdin.write(TerminalKeys.DOWN_ARROW as string); }); - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain('● Disable Auto Update'); }); @@ -425,14 +422,14 @@ describe('SettingsDialog', () => { stdin.write(TerminalKeys.ENTER as string); }); // Wait for the setting change to be processed - await waitFor(() => { + await vi.waitFor(() => { expect( vi.mocked(saveModifiedSettings).mock.calls.length, ).toBeGreaterThan(0); }); // Wait for the mock to be called - await waitFor(() => { + await vi.waitFor(() => { expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled(); }); @@ -470,7 +467,7 @@ describe('SettingsDialog', () => { await wait(); stdin.write(TerminalKeys.ENTER as string); await wait(); - await waitFor(() => { + await vi.waitFor(() => { expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled(); }); @@ -507,7 +504,7 @@ describe('SettingsDialog', () => { await wait(); stdin.write(TerminalKeys.ENTER as string); await wait(); - await waitFor(() => { + await vi.waitFor(() => { expect(vi.mocked(saveModifiedSettings)).toHaveBeenCalled(); }); @@ -596,7 +593,7 @@ describe('SettingsDialog', () => { ); // Wait for initial render - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); @@ -668,7 +665,7 @@ describe('SettingsDialog', () => { ); // Wait for initial render - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain('Hide Window Title'); }); @@ -964,7 +961,7 @@ describe('SettingsDialog', () => { await wait(50); } - await waitFor(() => { + await vi.waitFor(() => { expect( vi.mocked(saveModifiedSettings).mock.calls.length, ).toBeGreaterThan(0); @@ -1024,7 +1021,7 @@ describe('SettingsDialog', () => { await wait(30); } - await waitFor(() => { + await vi.waitFor(() => { expect( vi.mocked(saveModifiedSettings).mock.calls.length, ).toBeGreaterThan(0); @@ -1141,7 +1138,7 @@ describe('SettingsDialog', () => { ); // Wait for initial render - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); @@ -1203,7 +1200,7 @@ describe('SettingsDialog', () => { ); // Wait for initial render - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toContain('Vim Mode'); }); diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx index bc2fd37db3..9f9b9e60de 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx @@ -4,10 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { waitFor } from '@testing-library/react'; import { renderWithProviders } from '../../../test-utils/render.js'; import { BaseSelectionList, @@ -301,7 +298,7 @@ describe('BaseSelectionList', () => { rerender(); - await waitFor(() => { + await vi.waitFor(() => { expect(lastFrame()).toBeTruthy(); }); }; @@ -325,7 +322,7 @@ describe('BaseSelectionList', () => { // New visible window should be Items 2, 3, 4 (scroll offset 1). await updateActiveIndex(3); - await waitFor(() => { + await vi.waitFor(() => { const output = lastFrame(); expect(output).not.toContain('Item 1'); expect(output).toContain('Item 2'); @@ -339,7 +336,7 @@ describe('BaseSelectionList', () => { await updateActiveIndex(4); - await waitFor(() => { + await vi.waitFor(() => { const output = lastFrame(); expect(output).toContain('Item 3'); // Should see items 3, 4, 5 expect(output).toContain('Item 5'); @@ -350,7 +347,7 @@ describe('BaseSelectionList', () => { // This should trigger scroll up to show items 2, 3, 4 await updateActiveIndex(1); - await waitFor(() => { + await vi.waitFor(() => { const output = lastFrame(); expect(output).toContain('Item 2'); expect(output).toContain('Item 4'); @@ -364,7 +361,7 @@ describe('BaseSelectionList', () => { // Visible items: 8, 9, 10. const { lastFrame } = renderScrollableList(9); - await waitFor(() => { + await vi.waitFor(() => { const output = lastFrame(); expect(output).toContain('Item 10'); expect(output).toContain('Item 8'); @@ -383,14 +380,14 @@ describe('BaseSelectionList', () => { expect(lastFrame()).toContain('Item 1'); await updateActiveIndex(3); // Should trigger scroll - await waitFor(() => { + await vi.waitFor(() => { const output = lastFrame(); expect(output).toContain('Item 2'); expect(output).toContain('Item 4'); expect(output).not.toContain('Item 1'); }); await updateActiveIndex(5); // Scroll further - await waitFor(() => { + await vi.waitFor(() => { const output = lastFrame(); expect(output).toContain('Item 4'); expect(output).toContain('Item 6'); @@ -417,7 +414,7 @@ describe('BaseSelectionList', () => { it('should correctly identify the selected item when scrolled (high index)', async () => { renderScrollableList(5); - await waitFor(() => { + await vi.waitFor(() => { // Item 6 (index 5) should be selected expect(mockRenderItem).toHaveBeenCalledWith( expect.objectContaining({ value: 'Item 6' }), @@ -475,7 +472,7 @@ describe('BaseSelectionList', () => { 0, ); - await waitFor(() => { + await vi.waitFor(() => { const output = lastFrame(); // At the top, should show first 3 items expect(output).toContain('Item 1'); @@ -493,7 +490,7 @@ describe('BaseSelectionList', () => { 5, ); - await waitFor(() => { + await vi.waitFor(() => { const output = lastFrame(); // After scrolling to middle, should see items around index 5 expect(output).toContain('Item 4'); @@ -512,7 +509,7 @@ describe('BaseSelectionList', () => { 9, ); - await waitFor(() => { + await vi.waitFor(() => { const output = lastFrame(); // At the end, should show last 3 items expect(output).toContain('Item 8'); diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 77013f27b5..fa68800f87 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -4,11 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { describe, it, expect, beforeEach } from 'vitest'; import stripAnsi from 'strip-ansi'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../../test-utils/render.js'; import type { Viewport, TextBuffer, diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 4f1aa42e69..3d11de50b7 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -4,10 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import type React from 'react'; -import { renderHook, act, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import type { Mock } from 'vitest'; import { vi } from 'vitest'; import type { Key } from './KeypressContext.js'; @@ -370,7 +369,7 @@ describe('KeypressContext - Kitty Protocol', () => { stdin.write(PASTE_END); }); - await waitFor(() => { + await vi.waitFor(() => { // Expect the handler to be called exactly once for the entire paste expect(keyHandler).toHaveBeenCalledTimes(1); }); @@ -399,7 +398,7 @@ describe('KeypressContext - Kitty Protocol', () => { stdin.write(PASTE_END); }); - await waitFor(() => { + await vi.waitFor(() => { expect(keyHandler).toHaveBeenCalledTimes(1); }); @@ -427,7 +426,7 @@ describe('KeypressContext - Kitty Protocol', () => { stdin.write(PASTE_END.slice(3)); }); - await waitFor(() => { + await vi.waitFor(() => { expect(keyHandler).toHaveBeenCalledTimes(1); }); @@ -1193,7 +1192,7 @@ describe('Kitty Sequence Parsing', () => { } // Should parse once complete - await waitFor(() => { + await vi.waitFor(() => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ name: 'escape', diff --git a/packages/cli/src/ui/contexts/SessionContext.test.tsx b/packages/cli/src/ui/contexts/SessionContext.test.tsx index 45833ae5ee..b2602e3925 100644 --- a/packages/cli/src/ui/contexts/SessionContext.test.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.test.tsx @@ -4,17 +4,40 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - -import { type MutableRefObject } from 'react'; +import { type MutableRefObject, Component, type ReactNode } from 'react'; import { render } from 'ink-testing-library'; -import { renderHook } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; + +import { act } from 'react'; import type { SessionMetrics } from './SessionContext.js'; import { SessionStatsProvider, useSessionStats } from './SessionContext.js'; import { describe, it, expect, vi } from 'vitest'; import { uiTelemetryService } from '@google/gemini-cli-core'; +class ErrorBoundary extends Component< + { children: ReactNode; onError: (error: Error) => void }, + { hasError: boolean } +> { + constructor(props: { children: ReactNode; onError: (error: Error) => void }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(_error: Error) { + return { hasError: true }; + } + + override componentDidCatch(error: Error) { + this.props.onError(error); + } + + override render() { + if (this.state.hasError) { + return null; + } + return this.props.children; + } +} + /** * A test harness component that uses the hook and exposes the context value * via a mutable ref. This allows us to interact with the context's functions @@ -208,16 +231,22 @@ describe('SessionStatsContext', () => { }); it('should throw an error when useSessionStats is used outside of a provider', () => { - // Suppress console.error for this test since we expect an error + const onError = vi.fn(); + // Suppress console.error from React for this test const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - try { - // Expect renderHook itself to throw when the hook is used outside a provider - expect(() => { - renderHook(() => useSessionStats()); - }).toThrow('useSessionStats must be used within a SessionStatsProvider'); - } finally { - consoleSpy.mockRestore(); - } + render( + + + , + ); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'useSessionStats must be used within a SessionStatsProvider', + }), + ); + + consoleSpy.mockRestore(); }); }); diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts index 5b4687f02c..42c63ae62b 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts @@ -4,16 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { renderHook, waitFor, act } from '@testing-library/react'; +import { act, useState } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { useAtCompletion } from './useAtCompletion.js'; import type { Config, FileSearch } from '@google/gemini-cli-core'; import { FileSearchFactory } from '@google/gemini-cli-core'; import type { FileSystemStructure } from '@google/gemini-cli-test-utils'; import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; -import { useState } from 'react'; import type { Suggestion } from '../components/SuggestionsDisplay.js'; // Test harness to capture the state from the hook's callbacks. @@ -76,7 +74,7 @@ describe('useAtCompletion', () => { useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); @@ -106,7 +104,7 @@ describe('useAtCompletion', () => { useTestHarnessForAtCompletion(true, 'src/', mockConfig, testRootDir), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); @@ -129,7 +127,7 @@ describe('useAtCompletion', () => { useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); @@ -166,7 +164,7 @@ describe('useAtCompletion', () => { ); // The hook should find 'cRaZycAsE.txt' even though the pattern is 'CrAzYCaSe'. - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'cRaZycAsE.txt', ]); @@ -177,15 +175,29 @@ describe('useAtCompletion', () => { describe('UI State and Loading Behavior', () => { it('should be in a loading state during initial file system crawl', async () => { testRootDir = await createTmpDir({}); + + // Mock FileSearch to be slow to catch the loading state + const mockFileSearch = { + initialize: vi.fn().mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }), + search: vi.fn().mockResolvedValue([]), + }; + vi.spyOn(FileSearchFactory, 'create').mockReturnValue( + mockFileSearch as unknown as FileSearch, + ); + const { result } = renderHook(() => useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); // It's initially true because the effect runs synchronously. - expect(result.current.isLoadingSuggestions).toBe(true); + await vi.waitFor(() => { + expect(result.current.isLoadingSuggestions).toBe(true); + }); // Wait for the loading to complete. - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(false); }); }); @@ -200,7 +212,7 @@ describe('useAtCompletion', () => { { initialProps: { pattern: 'a' } }, ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'a.txt', ]); @@ -210,7 +222,7 @@ describe('useAtCompletion', () => { rerender({ pattern: 'b' }); // Wait for the final result - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'b.txt', ]); @@ -253,7 +265,7 @@ describe('useAtCompletion', () => { ); // Wait for the initial search to complete (using real timers) - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'a.txt', ]); @@ -283,7 +295,7 @@ describe('useAtCompletion', () => { vi.useRealTimers(); // Wait for the search results to be processed - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'b.txt', ]); @@ -314,7 +326,7 @@ describe('useAtCompletion', () => { ); // Wait for the hook to be ready (initialization is complete) - await waitFor(() => { + await vi.waitFor(() => { expect(mockFileSearch.search).toHaveBeenCalledWith( 'a', expect.any(Object), @@ -330,7 +342,7 @@ describe('useAtCompletion', () => { expect(abortSpy).toHaveBeenCalledTimes(1); // Wait for the final result, which should be from the second, faster search. - await waitFor( + await vi.waitFor( () => { expect(result.current.suggestions.map((s) => s.value)).toEqual(['b']); }, @@ -357,7 +369,7 @@ describe('useAtCompletion', () => { ); // Wait for the hook to be ready and have suggestions - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'a.txt', ]); @@ -389,7 +401,7 @@ describe('useAtCompletion', () => { ); // Wait for the hook to enter the error state - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(false); }); expect(result.current.suggestions).toEqual([]); // No suggestions on error @@ -420,7 +432,7 @@ describe('useAtCompletion', () => { useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); @@ -441,7 +453,7 @@ describe('useAtCompletion', () => { useTestHarnessForAtCompletion(true, '', undefined, testRootDir), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); @@ -469,7 +481,7 @@ describe('useAtCompletion', () => { ); // Wait for initial suggestions from the first directory - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'file1.txt', ]); @@ -481,13 +493,13 @@ describe('useAtCompletion', () => { }); // After CWD changes, suggestions should be cleared and it should load again. - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.isLoadingSuggestions).toBe(true); expect(result.current.suggestions).toEqual([]); }); // Wait for the new suggestions from the second directory - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.map((s) => s.value)).toEqual([ 'file2.txt', ]); @@ -525,7 +537,7 @@ describe('useAtCompletion', () => { ), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions.length).toBeGreaterThan(0); }); diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts index 25b515de6b..910c0960c0 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { describe, it, @@ -15,7 +13,8 @@ import { type MockedFunction, type Mock, } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { useAutoAcceptIndicator } from './useAutoAcceptIndicator.js'; import { Config, ApprovalMode } from '@google/gemini-cli-core'; diff --git a/packages/cli/src/ui/hooks/useFlickerDetector.test.ts b/packages/cli/src/ui/hooks/useFlickerDetector.test.ts index aa60378648..cbe5e4f14e 100644 --- a/packages/cli/src/ui/hooks/useFlickerDetector.test.ts +++ b/packages/cli/src/ui/hooks/useFlickerDetector.test.ts @@ -4,9 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - -import { renderHook } from '@testing-library/react'; +import { renderHook } from '../../test-utils/render.js'; import { vi, type Mock } from 'vitest'; import { useFlickerDetector } from './useFlickerDetector.js'; import { useConfig } from '../contexts/ConfigContext.js'; @@ -19,10 +17,15 @@ import { appEvents, AppEvent } from '../../utils/events.js'; // Mock dependencies vi.mock('../contexts/ConfigContext.js'); vi.mock('../contexts/UIStateContext.js'); -vi.mock('@google/gemini-cli-core', () => ({ - recordFlickerFrame: vi.fn(), - GEMINI_DIR: '.gemini', -})); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + recordFlickerFrame: vi.fn(), + GEMINI_DIR: '.gemini', + }; +}); vi.mock('ink', async (importOriginal) => { const original = await importOriginal(); return { diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index cc663a11d9..25609ab65c 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -4,10 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { vi, type Mock, type MockInstance } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { useFolderTrust } from './useFolderTrust.js'; import type { LoadedSettings } from '../../config/settings.js'; import { FolderTrustChoice } from '../components/FolderTrustDialog.js'; @@ -30,7 +29,6 @@ vi.mock('node:process', async () => { describe('useFolderTrust', () => { let mockSettings: LoadedSettings; let mockTrustedFolders: LoadedTrustedFolders; - let loadTrustedFoldersSpy: MockInstance; let isWorkspaceTrustedSpy: MockInstance; let onTrustChange: (isTrusted: boolean | undefined) => void; let addItem: Mock; @@ -51,9 +49,9 @@ describe('useFolderTrust', () => { setValue: vi.fn(), } as unknown as LoadedTrustedFolders; - loadTrustedFoldersSpy = vi - .spyOn(trustedFolders, 'loadTrustedFolders') - .mockReturnValue(mockTrustedFolders); + vi.spyOn(trustedFolders, 'loadTrustedFolders').mockReturnValue( + mockTrustedFolders, + ); isWorkspaceTrustedSpy = vi.spyOn(trustedFolders, 'isWorkspaceTrusted'); mockedCwd.mockReturnValue('/test/path'); onTrustChange = vi.fn(); @@ -82,7 +80,7 @@ describe('useFolderTrust', () => { expect(onTrustChange).toHaveBeenCalledWith(false); }); - it('should open dialog when folder trust is undefined', () => { + it('should open dialog when folder trust is undefined', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: undefined, source: undefined, @@ -90,7 +88,9 @@ describe('useFolderTrust', () => { const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); - expect(result.current.isFolderTrustDialogOpen).toBe(true); + await vi.waitFor(() => { + expect(result.current.isFolderTrustDialogOpen).toBe(true); + }); expect(onTrustChange).toHaveBeenCalledWith(undefined); }); @@ -112,26 +112,41 @@ describe('useFolderTrust', () => { expect(addItem).not.toHaveBeenCalled(); }); - it('should handle TRUST_FOLDER choice', () => { + it('should handle TRUST_FOLDER choice', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: undefined, source: undefined, }); + + (mockTrustedFolders.setValue as Mock).mockImplementation(() => { + isWorkspaceTrustedSpy.mockReturnValue({ + isTrusted: true, + source: 'file', + }); + }); + const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); - act(() => { - result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER); + await vi.waitFor(() => { + expect(result.current.isTrusted).toBeUndefined(); }); - expect(loadTrustedFoldersSpy).toHaveBeenCalled(); - expect(mockTrustedFolders.setValue).toHaveBeenCalledWith( - '/test/path', - TrustLevel.TRUST_FOLDER, - ); - expect(result.current.isFolderTrustDialogOpen).toBe(false); - expect(onTrustChange).toHaveBeenLastCalledWith(true); + await act(async () => { + await result.current.handleFolderTrustSelect( + FolderTrustChoice.TRUST_FOLDER, + ); + }); + + await vi.waitFor(() => { + expect(mockTrustedFolders.setValue).toHaveBeenCalledWith( + '/test/path', + TrustLevel.TRUST_FOLDER, + ); + expect(result.current.isFolderTrustDialogOpen).toBe(false); + expect(onTrustChange).toHaveBeenLastCalledWith(true); + }); }); it('should handle TRUST_PARENT choice', () => { @@ -177,7 +192,7 @@ describe('useFolderTrust', () => { expect(result.current.isFolderTrustDialogOpen).toBe(true); }); - it('should do nothing for default choice', () => { + it('should do nothing for default choice', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: undefined, source: undefined, @@ -192,24 +207,40 @@ describe('useFolderTrust', () => { ); }); - expect(mockTrustedFolders.setValue).not.toHaveBeenCalled(); - expect(mockSettings.setValue).not.toHaveBeenCalled(); - expect(result.current.isFolderTrustDialogOpen).toBe(true); - expect(onTrustChange).toHaveBeenCalledWith(undefined); + await vi.waitFor(() => { + expect(mockTrustedFolders.setValue).not.toHaveBeenCalled(); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(result.current.isFolderTrustDialogOpen).toBe(true); + expect(onTrustChange).toHaveBeenCalledWith(undefined); + }); }); - it('should set isRestarting to true when trust status changes from false to true', () => { + it('should set isRestarting to true when trust status changes from false to true', async () => { isWorkspaceTrustedSpy.mockReturnValue({ isTrusted: false, source: 'file' }); // Initially untrusted + + (mockTrustedFolders.setValue as Mock).mockImplementation(() => { + isWorkspaceTrustedSpy.mockReturnValue({ + isTrusted: true, + source: 'file', + }); + }); + const { result } = renderHook(() => useFolderTrust(mockSettings, onTrustChange, addItem), ); + await vi.waitFor(() => { + expect(result.current.isTrusted).toBe(false); + }); + act(() => { result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER); }); - expect(result.current.isRestarting).toBe(true); - expect(result.current.isFolderTrustDialogOpen).toBe(true); // Dialog should stay open + await vi.waitFor(() => { + expect(result.current.isRestarting).toBe(true); + expect(result.current.isFolderTrustDialogOpen).toBe(true); // Dialog should stay open + }); }); it('should not set isRestarting to true when trust status does not change', () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 37698a09b9..ae3566feba 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -4,12 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Mock, MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { renderHook, act, waitFor } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { useGeminiStream } from './useGeminiStream.js'; import { useKeypress } from './useKeypress.js'; import * as atCommandProcessor from './atCommandProcessor.js'; @@ -507,7 +506,7 @@ describe('useGeminiStream', () => { } }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockMarkToolsAsSubmitted).toHaveBeenCalledTimes(1); expect(mockSendMessageStream).toHaveBeenCalledTimes(1); }); @@ -590,7 +589,7 @@ describe('useGeminiStream', () => { } }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['1']); expect(client.addHistory).toHaveBeenCalledWith({ role: 'user', @@ -702,7 +701,7 @@ describe('useGeminiStream', () => { } }); - await waitFor(() => { + await vi.waitFor(() => { // The tools should be marked as submitted locally expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith([ 'cancel-1', @@ -840,7 +839,7 @@ describe('useGeminiStream', () => { }); // 5. Wait for submitQuery to be called - await waitFor(() => { + await vi.waitFor(() => { expect(mockSendMessageStream).toHaveBeenCalledWith( toolCallResponseParts, expect.any(AbortSignal), @@ -889,7 +888,7 @@ describe('useGeminiStream', () => { }); // Wait for the first part of the response - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.streamingState).toBe(StreamingState.Responding); }); @@ -897,7 +896,7 @@ describe('useGeminiStream', () => { simulateEscapeKeyPress(); // Verify cancellation message is added - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( { type: MessageType.INFO, @@ -1030,7 +1029,7 @@ describe('useGeminiStream', () => { result.current.submitQuery('long running query'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.streamingState).toBe(StreamingState.Responding); }); @@ -1138,7 +1137,7 @@ describe('useGeminiStream', () => { expect(mockCancelAllToolCalls).toHaveBeenCalled(); // A cancellation message should be added to history - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ text: 'Request cancelled.', @@ -1167,7 +1166,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/memory add "test fact"'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockScheduleToolCalls).toHaveBeenCalledWith( [ expect.objectContaining({ @@ -1194,7 +1193,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/help'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockHandleSlashCommand).toHaveBeenCalledWith('/help'); expect(mockScheduleToolCalls).not.toHaveBeenCalled(); expect(mockSendMessageStream).not.toHaveBeenCalled(); // No LLM call made @@ -1215,7 +1214,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/my-custom-command'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockHandleSlashCommand).toHaveBeenCalledWith( '/my-custom-command', ); @@ -1250,7 +1249,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/emptycmd'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockHandleSlashCommand).toHaveBeenCalledWith('/emptycmd'); expect(localMockSendMessageStream).toHaveBeenCalledWith( '', @@ -1268,7 +1267,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('// This is a line comment'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockHandleSlashCommand).not.toHaveBeenCalled(); expect(localMockSendMessageStream).toHaveBeenCalledWith( '// This is a line comment', @@ -1286,7 +1285,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/* This is a block comment */'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockHandleSlashCommand).not.toHaveBeenCalled(); expect(localMockSendMessageStream).toHaveBeenCalledWith( '/* This is a block comment */', @@ -1324,7 +1323,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('/about'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockHandleSlashCommand).not.toHaveBeenCalled(); }); }); @@ -1401,7 +1400,7 @@ describe('useGeminiStream', () => { } }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockPerformMemoryRefresh).toHaveBeenCalledTimes(1); }); }); @@ -1457,7 +1456,7 @@ describe('useGeminiStream', () => { }); // 3. Assertion - await waitFor(() => { + await vi.waitFor(() => { expect(mockParseAndFormatApiError).toHaveBeenCalledWith( 'Rate limit exceeded', mockAuthType, @@ -1990,7 +1989,7 @@ describe('useGeminiStream', () => { }); // Check that the info message was added - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( { type: 'info', @@ -2050,7 +2049,7 @@ describe('useGeminiStream', () => { }); // Check that the message was added without suggestion - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( { type: 'info', @@ -2105,7 +2104,7 @@ describe('useGeminiStream', () => { }); // Check that the message was added with suggestion - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( { type: 'info', @@ -2161,7 +2160,7 @@ describe('useGeminiStream', () => { }); // Check that onCancelSubmit was called - await waitFor(() => { + await vi.waitFor(() => { expect(onCancelSubmitSpy).toHaveBeenCalled(); }); }); @@ -2360,7 +2359,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery(`Test ${reason}`); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( { type: 'info', @@ -2487,7 +2486,7 @@ describe('useGeminiStream', () => { }); // Wait for the first response to complete - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'gemini', @@ -2520,7 +2519,7 @@ describe('useGeminiStream', () => { // We can verify this by checking that the LoadingIndicator would not show the previous thought // The actual thought state is internal to the hook, but we can verify the behavior // by ensuring the second response doesn't show the previous thought - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'gemini', @@ -2638,7 +2637,7 @@ describe('useGeminiStream', () => { }); // Verify cancellation message was added - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', @@ -2696,7 +2695,7 @@ describe('useGeminiStream', () => { }); // Verify error message was added - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', @@ -2747,7 +2746,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('test query'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); expect( typeof result.current.loopDetectionConfirmationRequest?.onComplete, @@ -2795,7 +2794,7 @@ describe('useGeminiStream', () => { }); // Wait for confirmation request to be set - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); }); @@ -2824,7 +2823,7 @@ describe('useGeminiStream', () => { ); // Verify that the request was retried - await waitFor(() => { + await vi.waitFor(() => { expect(mockSendMessageStream).toHaveBeenCalledTimes(2); expect(mockSendMessageStream).toHaveBeenNthCalledWith( 2, @@ -2860,7 +2859,7 @@ describe('useGeminiStream', () => { }); // Wait for confirmation request to be set - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); }); @@ -2907,7 +2906,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('first query'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); }); @@ -2957,7 +2956,7 @@ describe('useGeminiStream', () => { await result.current.submitQuery('second query'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); }); @@ -2980,7 +2979,7 @@ describe('useGeminiStream', () => { ); // Verify that the request was retried - await waitFor(() => { + await vi.waitFor(() => { expect(mockSendMessageStream).toHaveBeenCalledTimes(3); // 1st query, 2nd query, retry of 2nd query expect(mockSendMessageStream).toHaveBeenNthCalledWith( 3, @@ -3011,7 +3010,7 @@ describe('useGeminiStream', () => { }); // Verify that the content was added to history before the loop detection dialog - await waitFor(() => { + await vi.waitFor(() => { expect(mockAddItem).toHaveBeenCalledWith( expect.objectContaining({ type: 'gemini', @@ -3022,7 +3021,7 @@ describe('useGeminiStream', () => { }); // Then verify loop detection confirmation request was set - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.loopDetectionConfirmationRequest).not.toBeNull(); }); }); diff --git a/packages/cli/src/ui/hooks/useHistoryManager.test.ts b/packages/cli/src/ui/hooks/useHistoryManager.test.ts index d813379ac2..cff7ef69bf 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.test.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.test.ts @@ -4,10 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { describe, it, expect } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { useHistory } from './useHistoryManager.js'; import type { HistoryItem } from '../types.js'; diff --git a/packages/cli/src/ui/hooks/useInputHistory.test.ts b/packages/cli/src/ui/hooks/useInputHistory.test.ts index 55e0b63182..6d0d7fad2f 100644 --- a/packages/cli/src/ui/hooks/useInputHistory.test.ts +++ b/packages/cli/src/ui/hooks/useInputHistory.test.ts @@ -4,9 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - -import { act, renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { useInputHistory } from './useInputHistory.js'; describe('useInputHistory', () => { diff --git a/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts b/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts index 6953ce1b37..ee7aa7d86d 100644 --- a/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts +++ b/packages/cli/src/ui/hooks/useInputHistoryStore.test.ts @@ -4,9 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - -import { act, renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { useInputHistoryStore } from './useInputHistoryStore.js'; diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts index 9549274160..d317170c18 100644 --- a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts +++ b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts @@ -4,10 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - -/// - import { describe, it, @@ -17,7 +13,8 @@ import { afterEach, type Mock, } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { usePermissionsModifyTrust } from './usePermissionsModifyTrust.js'; import { TrustLevel } from '../../config/trustedFolders.js'; import type { LoadedSettings } from '../../config/settings.js'; diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.ts b/packages/cli/src/ui/hooks/usePhraseCycler.test.ts deleted file mode 100644 index bfa53ff8c8..0000000000 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** @vitest-environment jsdom */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; -import { - usePhraseCycler, - WITTY_LOADING_PHRASES, - PHRASE_CHANGE_INTERVAL_MS, -} from './usePhraseCycler.js'; - -describe('usePhraseCycler', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('should initialize with a witty phrase when not active and not waiting', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - const { result } = renderHook(() => usePhraseCycler(false, false)); - expect(WITTY_LOADING_PHRASES).toContain(result.current); - }); - - it('should show "Waiting for user confirmation..." when isWaiting is true', () => { - const { result, rerender } = renderHook( - ({ isActive, isWaiting }) => usePhraseCycler(isActive, isWaiting), - { initialProps: { isActive: true, isWaiting: false } }, - ); - rerender({ isActive: true, isWaiting: true }); - expect(result.current).toBe('Waiting for user confirmation...'); - }); - - it('should not cycle phrases if isActive is false and not waiting', () => { - const { result } = renderHook(() => usePhraseCycler(false, false)); - const initialPhrase = result.current; - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS * 2); - }); - expect(result.current).toBe(initialPhrase); - }); - - it('should cycle through witty phrases when isActive is true and not waiting', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - const { result } = renderHook(() => usePhraseCycler(true, false)); - // Initial phrase should be one of the witty phrases - expect(WITTY_LOADING_PHRASES).toContain(result.current); - - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); - }); - // Phrase should change and be one of the witty phrases - expect(WITTY_LOADING_PHRASES).toContain(result.current); - - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); - }); - expect(WITTY_LOADING_PHRASES).toContain(result.current); - }); - - it('should reset to a witty phrase when isActive becomes true after being false (and not waiting)', () => { - // Ensure there are at least two phrases for this test to be meaningful. - if (WITTY_LOADING_PHRASES.length < 2) { - return; - } - - // Mock Math.random to make the test deterministic. - const mockRandomValues = [ - 0.5, // -> witty - 0, // -> index 0 - 0.5, // -> witty - 1 / WITTY_LOADING_PHRASES.length, // -> index 1 - 0.5, // -> witty - 0, // -> index 0 - ]; - let randomCallCount = 0; - vi.spyOn(Math, 'random').mockImplementation(() => { - const val = mockRandomValues[randomCallCount % mockRandomValues.length]; - randomCallCount++; - return val; - }); - - const { result, rerender } = renderHook( - ({ isActive, isWaiting }) => usePhraseCycler(isActive, isWaiting), - { initialProps: { isActive: false, isWaiting: false } }, - ); - - // Activate - rerender({ isActive: true, isWaiting: false }); - const firstActivePhrase = result.current; - expect(WITTY_LOADING_PHRASES).toContain(firstActivePhrase); - // With our mock, this should be the first phrase. - expect(firstActivePhrase).toBe(WITTY_LOADING_PHRASES[0]); - - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); - }); - - // Phrase should change to the second phrase. - expect(result.current).not.toBe(firstActivePhrase); - expect(result.current).toBe(WITTY_LOADING_PHRASES[1]); - - // Set to inactive - should reset to the default initial phrase - rerender({ isActive: false, isWaiting: false }); - expect(WITTY_LOADING_PHRASES).toContain(result.current); - - // Set back to active - should pick a random witty phrase (which our mock controls) - act(() => { - rerender({ isActive: true, isWaiting: false }); - }); - // The random mock will now return 0, so it should be the first phrase again. - expect(result.current).toBe(WITTY_LOADING_PHRASES[0]); - }); - - it('should clear phrase interval on unmount when active', () => { - const { unmount } = renderHook(() => usePhraseCycler(true, false)); - const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); - unmount(); - expect(clearIntervalSpy).toHaveBeenCalledOnce(); - }); - - it('should use custom phrases when provided', () => { - const customPhrases = ['Custom Phrase 1', 'Custom Phrase 2']; - let callCount = 0; - const randomMock = vi.spyOn(Math, 'random').mockImplementation(() => { - const val = callCount % 2; - callCount++; - return val / customPhrases.length; - }); - - const { result, rerender } = renderHook( - ({ isActive, isWaiting, customPhrases: phrases }) => - usePhraseCycler(isActive, isWaiting, phrases), - { - initialProps: { - isActive: true, - isWaiting: false, - customPhrases, - }, - }, - ); - - expect(result.current).toBe(customPhrases[0]); - - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); - }); - - expect(result.current).toBe(customPhrases[1]); - - // Test fallback to default phrases. - randomMock.mockRestore(); - vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - - rerender({ isActive: true, isWaiting: false, customPhrases: [] }); - - expect(WITTY_LOADING_PHRASES).toContain(result.current); - }); - - it('should fall back to witty phrases if custom phrases are an empty array', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - const { result } = renderHook( - ({ isActive, isWaiting, customPhrases: phrases }) => - usePhraseCycler(isActive, isWaiting, phrases), - { - initialProps: { - isActive: true, - isWaiting: false, - customPhrases: [], - }, - }, - ); - - expect(WITTY_LOADING_PHRASES).toContain(result.current); - }); - - it('should reset to a witty phrase when transitioning from waiting to active', () => { - vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty - const { result, rerender } = renderHook( - ({ isActive, isWaiting }) => usePhraseCycler(isActive, isWaiting), - { initialProps: { isActive: true, isWaiting: false } }, - ); - - expect(WITTY_LOADING_PHRASES).toContain(result.current); - - // Cycle to a different phrase (potentially) - act(() => { - vi.advanceTimersByTime(PHRASE_CHANGE_INTERVAL_MS); - }); - if (WITTY_LOADING_PHRASES.length > 1) { - // This check is probabilistic with random selection - } - expect(WITTY_LOADING_PHRASES).toContain(result.current); - - // Go to waiting state - rerender({ isActive: false, isWaiting: true }); - expect(result.current).toBe('Waiting for user confirmation...'); - - // Go back to active cycling - should pick a random witty phrase - rerender({ isActive: true, isWaiting: false }); - expect(WITTY_LOADING_PHRASES).toContain(result.current); - }); -}); diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx new file mode 100644 index 0000000000..3e83b97536 --- /dev/null +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -0,0 +1,216 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { + usePhraseCycler, + WITTY_LOADING_PHRASES, + PHRASE_CHANGE_INTERVAL_MS, +} from './usePhraseCycler.js'; + +// Test component to consume the hook +const TestComponent = ({ + isActive, + isWaiting, + customPhrases, +}: { + isActive: boolean; + isWaiting: boolean; + customPhrases?: string[]; +}) => { + const phrase = usePhraseCycler(isActive, isWaiting, customPhrases); + return {phrase}; +}; + +describe('usePhraseCycler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should initialize with a witty phrase when not active and not waiting', () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + const { lastFrame } = render( + , + ); + expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); + }); + + it('should show "Waiting for user confirmation..." when isWaiting is true', async () => { + const { lastFrame, rerender } = render( + , + ); + rerender(); + await vi.advanceTimersByTimeAsync(0); + expect(lastFrame()).toBe('Waiting for user confirmation...'); + }); + + it('should not cycle phrases if isActive is false and not waiting', async () => { + const { lastFrame } = render( + , + ); + const initialPhrase = lastFrame(); + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS * 2); + expect(lastFrame()).toBe(initialPhrase); + }); + + it('should cycle through witty phrases when isActive is true and not waiting', async () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + const { lastFrame } = render( + , + ); + // Initial phrase should be one of the witty phrases + await vi.advanceTimersByTimeAsync(0); + expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); + + await act(async () => { + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100); + }); + expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); + + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); + expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); + }); + + it('should reset to a phrase when isActive becomes true after being false', async () => { + const customPhrases = ['Phrase A', 'Phrase B']; + let callCount = 0; + vi.spyOn(Math, 'random').mockImplementation(() => { + // For custom phrases, only 1 Math.random call is made per update. + // 0 -> index 0 ('Phrase A') + // 0.99 -> index 1 ('Phrase B') + const val = callCount % 2 === 0 ? 0 : 0.99; + callCount++; + return val; + }); + + const { lastFrame, rerender } = render( + , + ); + + // Activate -> callCount 0 -> returns 0 -> 'Phrase A' + rerender( + , + ); + await vi.advanceTimersByTimeAsync(0); + expect(lastFrame()).toBe('Phrase A'); + + // Interval -> callCount 1 -> returns 0.99 -> 'Phrase B' + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); + expect(lastFrame()).toBe('Phrase B'); + + // Deactivate -> resets to customPhrases[0] -> 'Phrase A' + rerender( + , + ); + await vi.advanceTimersByTimeAsync(0); + expect(lastFrame()).toBe('Phrase A'); + + // Activate again -> callCount 2 -> returns 0 -> 'Phrase A' + rerender( + , + ); + await vi.advanceTimersByTimeAsync(0); + expect(lastFrame()).toBe('Phrase A'); + }); + + it('should clear phrase interval on unmount when active', () => { + const { unmount } = render( + , + ); + const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + unmount(); + expect(clearIntervalSpy).toHaveBeenCalledOnce(); + }); + + it('should use custom phrases when provided', async () => { + const customPhrases = ['Custom Phrase 1', 'Custom Phrase 2']; + const randomMock = vi.spyOn(Math, 'random'); + randomMock.mockReturnValue(0); + + const { lastFrame, rerender } = render( + , + ); + + expect(lastFrame()).toBe('Custom Phrase 1'); + + randomMock.mockReturnValue(0.99); + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS + 100); + + expect(lastFrame()).toBe('Custom Phrase 2'); + + // Test fallback to default phrases. + randomMock.mockRestore(); + vi.spyOn(Math, 'random').mockReturnValue(0.5); // Always witty + + rerender( + , + ); + await vi.advanceTimersByTimeAsync(0); + + expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); + }); + + it('should fall back to witty phrases if custom phrases are an empty array', async () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + const { lastFrame } = render( + , + ); + await vi.advanceTimersByTimeAsync(0); + + expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); + }); + + it('should reset to a witty phrase when transitioning from waiting to active', async () => { + vi.spyOn(Math, 'random').mockImplementation(() => 0.5); // Always witty + const { lastFrame, rerender } = render( + , + ); + await vi.advanceTimersByTimeAsync(0); + + expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); + + // Cycle to a different phrase (potentially) + await vi.advanceTimersByTimeAsync(PHRASE_CHANGE_INTERVAL_MS); + expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); + + // Go to waiting state + rerender(); + await vi.advanceTimersByTimeAsync(0); + expect(lastFrame()).toBe('Waiting for user confirmation...'); + + // Go back to active cycling - should pick a random witty phrase + rerender(); + await vi.advanceTimersByTimeAsync(0); + expect(WITTY_LOADING_PHRASES).toContain(lastFrame()); + }); +}); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index e3a86009dd..edadbbacfc 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -4,8 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { vi, describe, @@ -15,7 +13,8 @@ import { afterEach, type Mock, } from 'vitest'; -import { act, renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { type Config, type FallbackModelHandler, diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts index ac38b5d1e4..84d948b64d 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.test.ts @@ -4,11 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { CoreToolScheduler } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; -import { renderHook } from '@testing-library/react'; +import { renderHook } from '../../test-utils/render.js'; import { vi, describe, it, expect, beforeEach } from 'vitest'; import { useReactToolScheduler } from './useReactToolScheduler.js'; diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx index 373696ce4c..0b41c69441 100644 --- a/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx +++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx @@ -4,10 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { describe, it, expect } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { useReverseSearchCompletion } from './useReverseSearchCompletion.js'; import { useTextBuffer } from '../components/shared/text-buffer.js'; diff --git a/packages/cli/src/ui/hooks/useShellHistory.test.ts b/packages/cli/src/ui/hooks/useShellHistory.test.ts index 865bc7cf3f..a682d0acb7 100644 --- a/packages/cli/src/ui/hooks/useShellHistory.test.ts +++ b/packages/cli/src/ui/hooks/useShellHistory.test.ts @@ -4,13 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - -import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { useShellHistory } from './useShellHistory.js'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import * as os from 'node:os'; import * as crypto from 'node:crypto'; import { GEMINI_DIR } from '@google/gemini-cli-core'; @@ -19,7 +18,14 @@ vi.mock('node:fs/promises', () => ({ writeFile: vi.fn(), mkdir: vi.fn(), })); -vi.mock('node:os'); +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); vi.mock('node:crypto'); vi.mock('node:fs', async (importOriginal) => { const actualFs = await importOriginal(); @@ -33,6 +39,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); const path = await import('node:path'); class Storage { + static getGlobalSettingsPath(): string { + return '/test/home/.gemini/settings.json'; + } getProjectTempDir(): string { return path.join('/test/home/', actual.GEMINI_DIR, 'tmp', 'mocked_hash'); } @@ -68,7 +77,6 @@ const MOCKED_HISTORY_FILE = path.join(MOCKED_HISTORY_DIR, 'shell_history'); describe('useShellHistory', () => { const mockedFs = vi.mocked(fs); - const mockedOs = vi.mocked(os); const mockedCrypto = vi.mocked(crypto); beforeEach(() => { @@ -77,7 +85,7 @@ describe('useShellHistory', () => { mockedFs.readFile.mockResolvedValue(''); mockedFs.writeFile.mockResolvedValue(undefined); mockedFs.mkdir.mockResolvedValue(undefined); - mockedOs.homedir.mockReturnValue(MOCKED_HOME_DIR); + mockHomedir.mockReturnValue(MOCKED_HOME_DIR); const hashMock = { update: vi.fn().mockReturnThis(), @@ -90,7 +98,7 @@ describe('useShellHistory', () => { mockedFs.readFile.mockResolvedValue('cmd1\ncmd2'); const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); - await waitFor(() => { + await vi.waitFor(() => { expect(mockedFs.readFile).toHaveBeenCalledWith( MOCKED_HISTORY_FILE, 'utf-8', @@ -113,7 +121,7 @@ describe('useShellHistory', () => { const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); - await waitFor(() => { + await vi.waitFor(() => { expect(mockedFs.readFile).toHaveBeenCalled(); }); @@ -128,13 +136,15 @@ describe('useShellHistory', () => { it('should add a command and write to the history file', async () => { const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); - await waitFor(() => expect(mockedFs.readFile).toHaveBeenCalled()); + await vi.waitFor(() => { + expect(mockedFs.readFile).toHaveBeenCalled(); + }); act(() => { result.current.addCommandToHistory('new_command'); }); - await waitFor(() => { + await vi.waitFor(() => { expect(mockedFs.mkdir).toHaveBeenCalledWith(MOCKED_HISTORY_DIR, { recursive: true, }); @@ -156,7 +166,9 @@ describe('useShellHistory', () => { const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); // Wait for history to be loaded: ['cmd3', 'cmd2', 'cmd1'] - await waitFor(() => expect(mockedFs.readFile).toHaveBeenCalled()); + await vi.waitFor(() => { + expect(mockedFs.readFile).toHaveBeenCalled(); + }); let command: string | null = null; @@ -200,7 +212,10 @@ describe('useShellHistory', () => { it('should not add empty or whitespace-only commands to history', async () => { const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); - await waitFor(() => expect(mockedFs.readFile).toHaveBeenCalled()); + + await vi.waitFor(() => { + expect(mockedFs.readFile).toHaveBeenCalled(); + }); act(() => { result.current.addCommandToHistory(' '); @@ -214,14 +229,18 @@ describe('useShellHistory', () => { mockedFs.readFile.mockResolvedValue(oldCommands.join('\n')); const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); - await waitFor(() => expect(mockedFs.readFile).toHaveBeenCalled()); + await vi.waitFor(() => { + expect(mockedFs.readFile).toHaveBeenCalled(); + }); act(() => { result.current.addCommandToHistory('new_cmd'); }); // Wait for the async write to happen and then inspect the arguments. - await waitFor(() => expect(mockedFs.writeFile).toHaveBeenCalled()); + await vi.waitFor(() => { + expect(mockedFs.writeFile).toHaveBeenCalled(); + }); // The hook stores history newest-first. // Initial state: ['old_cmd_119', ..., 'old_cmd_0'] @@ -240,15 +259,20 @@ describe('useShellHistory', () => { const { result } = renderHook(() => useShellHistory(MOCKED_PROJECT_ROOT)); // Initial state: ['cmd3', 'cmd2', 'cmd1'] - await waitFor(() => expect(mockedFs.readFile).toHaveBeenCalled()); + await vi.waitFor(() => { + expect(mockedFs.readFile).toHaveBeenCalled(); + }); act(() => { result.current.addCommandToHistory('cmd1'); }); // After re-adding 'cmd1': ['cmd1', 'cmd3', 'cmd2'] - // Written to file (reversed): ['cmd2', 'cmd3', 'cmd1'] - await waitFor(() => expect(mockedFs.writeFile).toHaveBeenCalled()); + expect(mockedFs.readFile).toHaveBeenCalled(); + + await vi.waitFor(() => { + expect(mockedFs.writeFile).toHaveBeenCalled(); + }); const writtenContent = mockedFs.writeFile.mock.calls[0][1] as string; const writtenLines = writtenContent.split('\n'); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 371af516e7..e569e783be 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -4,10 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - import { describe, it, expect, vi } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; +import { renderHook } from '../../test-utils/render.js'; import { useSlashCompletion } from './useSlashCompletion.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import { CommandKind } from '../commands/types.js'; @@ -205,10 +203,12 @@ describe('useSlashCompletion', () => { ), ); - expect(result.current.suggestions.length).toBe(slashCommands.length); - expect(result.current.suggestions.map((s) => s.label)).toEqual( - expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']), - ); + await vi.waitFor(() => { + expect(result.current.suggestions.length).toBe(slashCommands.length); + expect(result.current.suggestions.map((s) => s.label)).toEqual( + expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']), + ); + }); }); it('should filter commands based on partial input', async () => { @@ -224,7 +224,7 @@ describe('useSlashCompletion', () => { ), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions).toEqual([ { label: 'memory', @@ -253,7 +253,7 @@ describe('useSlashCompletion', () => { ), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions).toEqual([ { label: 'stats', @@ -369,8 +369,10 @@ describe('useSlashCompletion', () => { ), ); - expect(result.current.suggestions.length).toBe(1); - expect(result.current.suggestions[0].label).toBe('visible'); + await vi.waitFor(() => { + expect(result.current.suggestions.length).toBe(1); + expect(result.current.suggestions[0].label).toBe('visible'); + }); }); }); @@ -390,29 +392,31 @@ describe('useSlashCompletion', () => { const { result } = renderHook(() => useTestHarnessForSlashCompletion( true, - '/memory', + '/memory ', slashCommands, mockCommandContext, ), ); - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { - label: 'show', - value: 'show', - description: 'Show memory', - commandKind: CommandKind.BUILT_IN, - }, - { - label: 'add', - value: 'add', - description: 'Add to memory', - commandKind: CommandKind.BUILT_IN, - }, - ]), - ); + await vi.waitFor(() => { + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { + label: 'show', + value: 'show', + description: 'Show memory', + commandKind: CommandKind.BUILT_IN, + }, + { + label: 'add', + value: 'add', + description: 'Add to memory', + commandKind: CommandKind.BUILT_IN, + }, + ]), + ); + }); }); it('should suggest all sub-commands when the query ends with the parent command and a space', async () => { @@ -435,23 +439,25 @@ describe('useSlashCompletion', () => { ), ); - expect(result.current.suggestions).toHaveLength(2); - expect(result.current.suggestions).toEqual( - expect.arrayContaining([ - { - label: 'show', - value: 'show', - description: 'Show memory', - commandKind: CommandKind.BUILT_IN, - }, - { - label: 'add', - value: 'add', - description: 'Add to memory', - commandKind: CommandKind.BUILT_IN, - }, - ]), - ); + await vi.waitFor(() => { + expect(result.current.suggestions).toHaveLength(2); + expect(result.current.suggestions).toEqual( + expect.arrayContaining([ + { + label: 'show', + value: 'show', + description: 'Show memory', + commandKind: CommandKind.BUILT_IN, + }, + { + label: 'add', + value: 'add', + description: 'Add to memory', + commandKind: CommandKind.BUILT_IN, + }, + ]), + ); + }); }); it('should filter sub-commands by prefix', async () => { @@ -474,7 +480,7 @@ describe('useSlashCompletion', () => { ), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions).toEqual([ { label: 'add', @@ -547,7 +553,7 @@ describe('useSlashCompletion', () => { ), ); - await waitFor(() => { + await vi.waitFor(() => { expect(mockCompletionFn).toHaveBeenCalledWith( expect.objectContaining({ invocation: { @@ -560,7 +566,7 @@ describe('useSlashCompletion', () => { ); }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions).toEqual([ { label: 'my-chat-tag-1', value: 'my-chat-tag-1' }, { label: 'my-chat-tag-2', value: 'my-chat-tag-2' }, @@ -596,7 +602,7 @@ describe('useSlashCompletion', () => { ), ); - await waitFor(() => { + await vi.waitFor(() => { expect(mockCompletionFn).toHaveBeenCalledWith( expect.objectContaining({ invocation: { @@ -609,7 +615,7 @@ describe('useSlashCompletion', () => { ); }); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions).toHaveLength(3); }); }); @@ -639,9 +645,7 @@ describe('useSlashCompletion', () => { ), ); - await waitFor(() => { - expect(result.current.suggestions).toHaveLength(0); - }); + expect(result.current.suggestions).toHaveLength(0); }); }); @@ -714,7 +718,7 @@ describe('useSlashCompletion', () => { ), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions).toEqual([ { label: 'summarize', @@ -795,7 +799,7 @@ describe('useSlashCompletion', () => { ), ); - await waitFor(() => { + await vi.waitFor(() => { expect(result.current.suggestions).toEqual([ { label: 'custom-script', diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 11d1b7e7d8..59896ea487 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -4,12 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -/** @vitest-environment jsdom */ - /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; import { useReactToolScheduler, mapToDisplay, @@ -38,7 +37,14 @@ import { ToolCallStatus } from '../types.js'; // Mocks vi.mock('@google/gemini-cli-core', async () => { - const actual = await vi.importActual('@google/gemini-cli-core'); + const actual = await vi.importActual('@google/gemini-cli-core'); + // Patch CoreToolScheduler to have cancelAll if it's missing in the test environment + if ( + actual.CoreToolScheduler && + !actual.CoreToolScheduler.prototype.cancelAll + ) { + actual.CoreToolScheduler.prototype.cancelAll = vi.fn(); + } return { ...actual, ToolRegistry: vi.fn(), @@ -153,13 +159,13 @@ describe('useReactToolScheduler in YOLO Mode', () => { }); await act(async () => { - await vi.runAllTimersAsync(); // Process validation + await vi.advanceTimersByTimeAsync(0); // Process validation }); await act(async () => { - await vi.runAllTimersAsync(); // Process scheduling + await vi.advanceTimersByTimeAsync(0); // Process scheduling }); await act(async () => { - await vi.runAllTimersAsync(); // Process execution + await vi.advanceTimersByTimeAsync(0); // Process execution }); // Check that execute WAS called @@ -270,13 +276,13 @@ describe('useReactToolScheduler', () => { }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(mockTool.execute).toHaveBeenCalledWith(request.args); @@ -341,13 +347,13 @@ describe('useReactToolScheduler', () => { // Let the new call finish. await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(onComplete).toHaveBeenCalled(); }); @@ -375,11 +381,11 @@ describe('useReactToolScheduler', () => { schedule(request, new AbortController().signal); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); // validation await act(async () => { - await vi.runAllTimersAsync(); - }); // scheduling + await vi.advanceTimersByTimeAsync(0); // Process scheduling + }); // At this point, the tool is 'executing' and waiting on the promise. expect(result.current[0][0].status).toBe('executing'); @@ -390,7 +396,7 @@ describe('useReactToolScheduler', () => { }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(onComplete).toHaveBeenCalledWith([ @@ -423,10 +429,10 @@ describe('useReactToolScheduler', () => { schedule(request, new AbortController().signal); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(completedToolCalls).toHaveLength(1); @@ -462,10 +468,10 @@ describe('useReactToolScheduler', () => { schedule(request, new AbortController().signal); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(completedToolCalls).toHaveLength(1); @@ -497,13 +503,13 @@ describe('useReactToolScheduler', () => { schedule(request, new AbortController().signal); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(completedToolCalls).toHaveLength(1); @@ -532,7 +538,7 @@ describe('useReactToolScheduler', () => { schedule(request, new AbortController().signal); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); const waitingCall = result.current[0][0] as any; @@ -545,13 +551,13 @@ describe('useReactToolScheduler', () => { }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith( @@ -590,7 +596,7 @@ describe('useReactToolScheduler', () => { schedule(request, new AbortController().signal); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); const waitingCall = result.current[0][0] as any; @@ -602,10 +608,10 @@ describe('useReactToolScheduler', () => { await capturedOnConfirmForTest?.(ToolConfirmationOutcome.Cancel); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(mockOnUserConfirmForToolConfirmation).toHaveBeenCalledWith( @@ -665,7 +671,7 @@ describe('useReactToolScheduler', () => { schedule(request, new AbortController().signal); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(liveUpdateFn).toBeDefined(); @@ -675,14 +681,14 @@ describe('useReactToolScheduler', () => { liveUpdateFn?.('Live output 1'); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { liveUpdateFn?.('Live output 2'); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); act(() => { @@ -692,10 +698,10 @@ describe('useReactToolScheduler', () => { } as ToolResult); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(onComplete).toHaveBeenCalledWith([ @@ -753,16 +759,16 @@ describe('useReactToolScheduler', () => { schedule(requests, new AbortController().signal); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); expect(onComplete).toHaveBeenCalledTimes(1); @@ -845,16 +851,16 @@ describe('useReactToolScheduler', () => { schedule(request1, new AbortController().signal); }); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); schedule(request2, new AbortController().signal); await act(async () => { await vi.advanceTimersByTimeAsync(50); - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); }); expect(onComplete).toHaveBeenCalledWith([ @@ -867,9 +873,9 @@ describe('useReactToolScheduler', () => { // Wait for request2 to complete await act(async () => { await vi.advanceTimersByTimeAsync(50); - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); await act(async () => { - await vi.runAllTimersAsync(); + await vi.advanceTimersByTimeAsync(0); }); }); expect(onComplete).toHaveBeenCalledWith([