From 04f65f3d555ab1adcd3087717ddca463b867148a Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Wed, 18 Feb 2026 16:46:50 -0800 Subject: [PATCH] Migrate core render util to use xterm.js as part of the rendering loop. (#19044) --- package-lock.json | 57 +- package.json | 5 +- packages/cli/package.json | 4 +- .../cli/src/config/extensions/github.test.ts | 2 +- .../cli/src/config/trustedFolders.test.ts | 32 +- packages/cli/src/test-utils/AppRig.test.tsx | 7 +- packages/cli/src/test-utils/AppRig.tsx | 2 +- packages/cli/src/test-utils/async.ts | 4 +- packages/cli/src/test-utils/render.test.tsx | 91 ++- packages/cli/src/test-utils/render.tsx | 396 +++++++++++- packages/cli/src/ui/App.test.tsx | 151 +++-- packages/cli/src/ui/AppContainer.test.tsx | 3 +- .../cli/src/ui/IdeIntegrationNudge.test.tsx | 66 +- .../src/ui/__snapshots__/App.test.tsx.snap | 29 +- .../cli/src/ui/auth/ApiAuthDialog.test.tsx | 32 +- packages/cli/src/ui/auth/AuthDialog.test.tsx | 122 +++- .../cli/src/ui/auth/AuthInProgress.test.tsx | 66 +- .../LoginWithGoogleRestartDialog.test.tsx | 16 +- .../__snapshots__/ApiAuthDialog.test.tsx.snap | 3 +- .../__snapshots__/AuthDialog.test.tsx.snap | 97 +-- ...LoginWithGoogleRestartDialog.test.tsx.snap | 3 +- .../src/ui/commands/extensionsCommand.test.ts | 8 +- .../cli/src/ui/components/AboutBox.test.tsx | 32 +- .../AdminSettingsChangedDialog.test.tsx | 31 +- .../ui/components/AgentConfigDialog.test.tsx | 66 +- .../AlternateBufferQuittingDisplay.test.tsx | 36 +- .../cli/src/ui/components/AnsiOutput.test.tsx | 64 +- .../cli/src/ui/components/AppHeader.test.tsx | 53 +- .../components/ApprovalModeIndicator.test.tsx | 30 +- .../src/ui/components/AskUserDialog.test.tsx | 232 ++++--- .../BackgroundShellDisplay.test.tsx | 133 ++-- .../cli/src/ui/components/Banner.test.tsx | 12 +- .../cli/src/ui/components/Checklist.test.tsx | 29 +- .../src/ui/components/ChecklistItem.test.tsx | 15 +- .../cli/src/ui/components/CliSpinner.test.tsx | 16 +- .../cli/src/ui/components/Composer.test.tsx | 203 ++++--- .../ui/components/ConfigInitDisplay.test.tsx | 5 +- .../src/ui/components/ConsentPrompt.test.tsx | 23 +- .../components/ConsoleSummaryDisplay.test.tsx | 18 +- .../components/ContextSummaryDisplay.test.tsx | 38 +- .../components/ContextUsageDisplay.test.tsx | 18 +- .../ui/components/CopyModeWarning.test.tsx | 14 +- .../src/ui/components/DebugProfiler.test.tsx | 32 +- .../DetailedMessagesDisplay.test.tsx | 20 +- .../src/ui/components/DialogManager.test.tsx | 14 +- .../components/EditorSettingsDialog.test.tsx | 24 +- .../src/ui/components/ExitWarning.test.tsx | 28 +- .../ui/components/FolderTrustDialog.test.tsx | 46 +- .../cli/src/ui/components/Footer.test.tsx | 569 +++++++++++------- .../GeminiRespondingSpinner.test.tsx | 42 +- .../ui/components/GradientRegression.test.tsx | 45 +- packages/cli/src/ui/components/Help.test.tsx | 21 +- .../ui/components/HistoryItemDisplay.test.tsx | 110 ++-- .../ui/components/HookStatusDisplay.test.tsx | 30 +- .../components/IdeTrustChangeDialog.test.tsx | 53 +- .../src/ui/components/InputPrompt.test.tsx | 20 +- .../ui/components/LoadingIndicator.test.tsx | 190 +++--- .../LogoutConfirmationDialog.test.tsx | 40 +- .../LoopDetectionConfirmation.test.tsx | 14 +- .../src/ui/components/MainContent.test.tsx | 48 +- .../ui/components/MemoryUsageDisplay.test.tsx | 15 +- .../src/ui/components/ModelDialog.test.tsx | 57 +- .../ui/components/ModelStatsDisplay.test.tsx | 79 ++- .../MultiFolderTrustDialog.test.tsx | 47 +- .../components/NewAgentsNotification.test.tsx | 10 +- .../src/ui/components/Notifications.test.tsx | 48 +- .../PermissionsModifyTrustDialog.test.tsx | 46 +- .../components/QueuedMessageDisplay.test.tsx | 27 +- .../ui/components/QuittingDisplay.test.tsx | 14 +- .../src/ui/components/QuotaDisplay.test.tsx | 70 ++- .../components/RawMarkdownIndicator.test.tsx | 16 +- .../ui/components/RewindConfirmation.test.tsx | 22 +- .../src/ui/components/RewindViewer.test.tsx | 74 ++- .../cli/src/ui/components/RewindViewer.tsx | 20 +- .../src/ui/components/SessionBrowser.test.tsx | 35 +- .../SessionRetentionWarningDialog.test.tsx | 19 +- .../components/SessionSummaryDisplay.test.tsx | 18 +- .../src/ui/components/SettingsDialog.test.tsx | 544 +++++++++++++---- .../ui/components/ShellInputPrompt.test.tsx | 201 ++++--- .../ui/components/ShellModeIndicator.test.tsx | 8 +- .../src/ui/components/ShortcutsHelp.test.tsx | 16 +- .../src/ui/components/ShowMoreLines.test.tsx | 16 +- .../src/ui/components/StatsDisplay.test.tsx | 85 +-- .../src/ui/components/StatusDisplay.test.tsx | 50 +- .../src/ui/components/StickyHeader.test.tsx | 31 +- .../ui/components/SuggestionsDisplay.test.tsx | 32 +- packages/cli/src/ui/components/Table.test.tsx | 24 +- .../src/ui/components/ThemeDialog.test.tsx | 62 +- .../src/ui/components/ThemedGradient.test.tsx | 8 +- packages/cli/src/ui/components/Tips.test.tsx | 23 +- .../src/ui/components/ToastDisplay.test.tsx | 42 +- .../components/ToolConfirmationQueue.test.tsx | 45 +- .../ui/components/ToolStatsDisplay.test.tsx | 31 +- .../ui/components/UpdateNotification.test.tsx | 6 +- .../src/ui/components/UserIdentity.test.tsx | 27 +- .../ui/components/ValidationDialog.test.tsx | 55 +- .../AdminSettingsChangedDialog.test.tsx.snap | 9 +- ...ternateBufferQuittingDisplay.test.tsx.snap | 6 +- .../__snapshots__/AppHeader.test.tsx.snap | 24 +- .../ApprovalModeIndicator.test.tsx.snap | 30 +- .../__snapshots__/AskUserDialog.test.tsx.snap | 42 +- .../BackgroundShellDisplay.test.tsx.snap | 78 +-- .../__snapshots__/Banner.test.tsx.snap | 9 +- .../__snapshots__/Checklist.test.tsx.snap | 9 +- .../__snapshots__/ChecklistItem.test.tsx.snap | 28 +- .../__snapshots__/Composer.test.tsx.snap | 15 +- .../ConfigInitDisplay.test.tsx.snap | 12 +- .../ContextSummaryDisplay.test.tsx.snap | 13 +- .../DetailedMessagesDisplay.test.tsx.snap | 6 +- .../EditorSettingsDialog.test.tsx.snap | 3 +- .../ExitPlanModeDialog.test.tsx.snap | 50 +- .../__snapshots__/Footer.test.tsx.snap | 35 +- .../HistoryItemDisplay.test.tsx.snap | 6 +- .../HookStatusDisplay.test.tsx.snap | 15 +- .../__snapshots__/InputPrompt.test.tsx.snap | 89 +-- .../LoadingIndicator.test.tsx.snap | 3 +- .../LoopDetectionConfirmation.test.tsx.snap | 3 +- .../ModelStatsDisplay.test.tsx.snap | 27 +- .../NewAgentsNotification.test.tsx.snap | 74 +-- .../__snapshots__/Notifications.test.tsx.snap | 3 +- .../__snapshots__/QuotaDisplay.test.tsx.snap | 25 +- .../RewindConfirmation.test.tsx.snap | 9 +- .../__snapshots__/RewindViewer.test.tsx.snap | 526 ++++++++-------- .../SessionBrowser.test.tsx.snap | 12 +- ...essionRetentionWarningDialog.test.tsx.snap | 33 +- .../SessionSummaryDisplay.test.tsx.snap | 3 +- .../SettingsDialog.test.tsx.snap | 27 +- .../__snapshots__/ShortcutsHelp.test.tsx.snap | 12 +- .../__snapshots__/StatsDisplay.test.tsx.snap | 45 +- .../__snapshots__/StatusDisplay.test.tsx.snap | 20 +- .../SuggestionsDisplay.test.tsx.snap | 19 +- .../__snapshots__/Table.test.tsx.snap | 6 +- .../__snapshots__/ThemeDialog.test.tsx.snap | 193 +++--- .../__snapshots__/ToastDisplay.test.tsx.snap | 35 +- .../ToolConfirmationQueue.test.tsx.snap | 31 +- .../ToolStatsDisplay.test.tsx.snap | 15 +- .../messages/CompressionMessage.test.tsx | 140 ++--- .../components/messages/ErrorMessage.test.tsx | 16 +- .../messages/GeminiMessage.test.tsx | 18 +- .../components/messages/InfoMessage.test.tsx | 22 +- .../messages/RedirectionConfirmation.test.tsx | 6 +- .../messages/ShellToolMessage.test.tsx | 22 +- .../messages/ThinkingMessage.test.tsx | 38 +- .../src/ui/components/messages/Todo.test.tsx | 54 +- .../messages/ToolConfirmationMessage.test.tsx | 60 +- .../messages/ToolGroupMessage.test.tsx | 153 +++-- .../components/messages/ToolMessage.test.tsx | 144 +++-- .../messages/ToolMessageFocusHint.test.tsx | 21 +- .../messages/ToolMessageRawMarkdown.test.tsx | 6 +- .../messages/ToolResultDisplay.test.tsx | 83 ++- .../ToolStickyHeaderRegression.test.tsx | 11 +- .../components/messages/UserMessage.test.tsx | 24 +- .../messages/WarningMessage.test.tsx | 16 +- .../__snapshots__/DiffRenderer.test.tsx.snap | 80 ++- .../__snapshots__/InfoMessage.test.tsx.snap | 9 +- .../ShellToolMessage.test.tsx.snap | 27 +- .../messages/__snapshots__/Todo.test.tsx.snap | 27 +- .../ToolGroupMessage.test.tsx.snap | 22 +- .../__snapshots__/ToolMessage.test.tsx.snap | 42 +- .../ToolMessageFocusHint.test.tsx.snap | 27 +- .../ToolMessageRawMarkdown.test.tsx.snap | 18 +- .../ToolResultDisplay.test.tsx.snap | 31 +- .../ToolStickyHeaderRegression.test.tsx.snap | 15 +- .../__snapshots__/UserMessage.test.tsx.snap | 12 +- .../WarningMessage.test.tsx.snap | 6 +- .../shared/BaseSelectionList.test.tsx | 244 ++++---- .../shared/BaseSettingsDialog.test.tsx | 246 +++++--- .../DescriptiveRadioButtonSelect.test.tsx | 16 +- .../components/shared/EnumSelector.test.tsx | 107 ++-- .../components/shared/ExpandableText.test.tsx | 35 +- .../shared/HalfLinePaddedBox.test.tsx | 12 +- .../ui/components/shared/MaxSizedBox.test.tsx | 87 ++- .../ui/components/shared/Scrollable.test.tsx | 41 +- .../components/shared/ScrollableList.test.tsx | 63 +- .../ui/components/shared/ScrollableList.tsx | 5 +- .../components/shared/SearchableList.test.tsx | 5 +- .../components/shared/SectionHeader.test.tsx | 5 +- .../ui/components/shared/TabHeader.test.tsx | 74 ++- .../ui/components/shared/TextInput.test.tsx | 176 ++++-- .../shared/VirtualizedList.test.tsx | 85 ++- .../BaseSelectionList.test.tsx.snap | 12 +- ...DescriptiveRadioButtonSelect.test.tsx.snap | 6 +- .../__snapshots__/EnumSelector.test.tsx.snap | 20 +- .../ExpandableText.test.tsx.snap | 27 +- .../HalfLinePaddedBox.test.tsx.snap | 16 +- .../__snapshots__/MaxSizedBox.test.tsx.snap | 38 +- .../__snapshots__/Scrollable.test.tsx.snap | 1 - .../__snapshots__/SectionHeader.test.tsx.snap | 15 +- .../VirtualizedList.test.tsx.snap | 15 +- .../src/ui/components/views/ChatList.test.tsx | 19 +- .../components/views/ExtensionsList.test.tsx | 38 +- .../ui/components/views/McpStatus.test.tsx | 67 ++- .../ui/components/views/SkillsList.test.tsx | 30 +- .../ui/components/views/ToolsList.test.tsx | 15 +- .../__snapshots__/ChatList.test.tsx.snap | 11 +- .../src/ui/contexts/TerminalContext.test.tsx | 41 +- .../cli/src/ui/hooks/useAnimatedScrollbar.ts | 18 +- .../cli/src/ui/hooks/useMouseClick.test.ts | 26 +- .../cli/src/ui/hooks/usePhraseCycler.test.tsx | 297 +++++---- .../src/ui/hooks/useSelectionList.test.tsx | 198 ++++-- .../src/ui/hooks/useTerminalTheme.test.tsx | 60 +- .../src/ui/layouts/DefaultAppLayout.test.tsx | 18 +- .../DefaultAppLayout.test.tsx.snap | 9 +- .../privacy/CloudFreePrivacyNotice.test.tsx | 48 +- .../privacy/CloudPaidPrivacyNotice.test.tsx | 25 +- .../ui/privacy/GeminiPrivacyNotice.test.tsx | 25 +- .../cli/src/ui/privacy/PrivacyNotice.test.tsx | 6 +- .../cli/src/ui/utils/CodeColorizer.test.tsx | 8 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 98 ++- .../cli/src/ui/utils/TableRenderer.test.tsx | 65 +- .../MarkdownDisplay.test.tsx.snap | 63 +- packages/cli/src/utils/skillUtils.test.ts | 93 +-- packages/cli/test-setup.ts | 5 + 213 files changed, 7065 insertions(+), 3852 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5659d5f16f..aa298ff4b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "packages/*" ], "dependencies": { - "ink": "npm:@jrichman/ink@6.4.10", + "ink": "npm:@jrichman/ink@6.4.11", "latest-version": "^9.0.0", "proper-lockfile": "^4.1.2", "simple-git": "^3.28.0" @@ -48,7 +48,6 @@ "globals": "^16.0.0", "google-artifactregistry-auth": "^3.4.0", "husky": "^9.1.7", - "ink-testing-library": "^4.0.0", "json": "^11.0.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", @@ -2241,7 +2240,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", @@ -2422,7 +2420,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" } @@ -2472,7 +2469,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2847,7 +2843,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2881,7 +2876,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2936,7 +2930,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4110,7 +4103,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4385,7 +4377,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", @@ -5378,7 +5369,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" }, @@ -7918,7 +7908,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8439,7 +8428,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9736,7 +9724,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -10033,11 +10020,10 @@ }, "node_modules/ink": { "name": "@jrichman/ink", - "version": "6.4.10", - "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.10.tgz", - "integrity": "sha512-kjJqZFkGVm0QyJmga/L02rsFJroF1aP2bhXEGkpuuT7clB6/W+gxAbLNw7ZaJrG6T30DgqOT92Pu6C9mK1FWyg==", + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", + "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -10117,24 +10103,6 @@ "react": ">=18.0.0" } }, - "node_modules/ink-testing-library": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", - "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/react": ">=18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/ink/node_modules/ansi-styles": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", @@ -13718,7 +13686,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13729,7 +13696,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15912,7 +15878,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16136,8 +16101,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", @@ -16145,7 +16109,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16306,7 +16269,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16514,7 +16476,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16628,7 +16589,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16641,7 +16601,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17273,7 +17232,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" } @@ -17369,7 +17327,7 @@ "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.10", + "ink": "npm:@jrichman/ink@6.4.11", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", @@ -17404,7 +17362,7 @@ "@types/shell-quote": "^1.7.5", "@types/ws": "^8.5.10", "@types/yargs": "^17.0.32", - "ink-testing-library": "^4.0.0", + "@xterm/headless": "^5.5.0", "typescript": "^5.3.3", "vitest": "^3.1.1" }, @@ -17589,7 +17547,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/package.json b/package.json index 1750b0d99e..7df18f2490 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "pre-commit": "node scripts/pre-commit.js" }, "overrides": { - "ink": "npm:@jrichman/ink@6.4.10", + "ink": "npm:@jrichman/ink@6.4.11", "wrap-ansi": "9.0.2", "cliui": { "wrap-ansi": "7.0.0" @@ -107,7 +107,6 @@ "globals": "^16.0.0", "google-artifactregistry-auth": "^3.4.0", "husky": "^9.1.7", - "ink-testing-library": "^4.0.0", "json": "^11.0.0", "lint-staged": "^16.1.6", "memfs": "^4.42.0", @@ -127,7 +126,7 @@ "yargs": "^17.7.2" }, "dependencies": { - "ink": "npm:@jrichman/ink@6.4.10", + "ink": "npm:@jrichman/ink@6.4.11", "latest-version": "^9.0.0", "proper-lockfile": "^4.1.2", "simple-git": "^3.28.0" diff --git a/packages/cli/package.json b/packages/cli/package.json index d7793e8494..4d38b87a92 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,7 +48,7 @@ "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.10", + "ink": "npm:@jrichman/ink@6.4.11", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", @@ -80,7 +80,7 @@ "@types/shell-quote": "^1.7.5", "@types/ws": "^8.5.10", "@types/yargs": "^17.0.32", - "ink-testing-library": "^4.0.0", + "@xterm/headless": "^5.5.0", "typescript": "^5.3.3", "vitest": "^3.1.1" }, diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index 9424aaa07d..c3ff5905b5 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -269,7 +269,7 @@ describe('github.ts', () => { it('should return NOT_UPDATABLE if local extension config cannot be loaded', async () => { vi.mocked(mockExtensionManager.loadExtensionConfig).mockImplementation( - () => { + async () => { throw new Error('Config not found'); }, ); diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts index 892cd86e4b..714d703241 100644 --- a/packages/cli/src/config/trustedFolders.test.ts +++ b/packages/cli/src/config/trustedFolders.test.ts @@ -491,25 +491,33 @@ describe('Trusted Folders', () => { }); }); + const itif = (condition: boolean) => (condition ? it : it.skip); + describe('Symlinks Support', () => { const mockSettings: Settings = { security: { folderTrust: { enabled: true } }, }; - it('should trust a folder if the rule matches the realpath', () => { - // Create a real directory and a symlink - const realDir = path.join(tempDir, 'real'); - const symlinkDir = path.join(tempDir, 'symlink'); - fs.mkdirSync(realDir); - fs.symlinkSync(realDir, symlinkDir); + // TODO: issue 19387 - Enable symlink tests on Windows + itif(process.platform !== 'win32')( + 'should trust a folder if the rule matches the realpath', + () => { + // Create a real directory and a symlink + const realDir = path.join(tempDir, 'real'); + const symlinkDir = path.join(tempDir, 'symlink'); + fs.mkdirSync(realDir); + fs.symlinkSync(realDir, symlinkDir); - // Rule uses realpath - const config = { [realDir]: TrustLevel.TRUST_FOLDER }; - fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); + // Rule uses realpath + const config = { [realDir]: TrustLevel.TRUST_FOLDER }; + fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8'); - // Check against symlink path - expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe(true); - }); + // Check against symlink path + expect(isWorkspaceTrusted(mockSettings, symlinkDir).isTrusted).toBe( + true, + ); + }, + ); }); describe('Verification: Auth and Trust Interaction', () => { diff --git a/packages/cli/src/test-utils/AppRig.test.tsx b/packages/cli/src/test-utils/AppRig.test.tsx index bada7965f7..76c0ddc522 100644 --- a/packages/cli/src/test-utils/AppRig.test.tsx +++ b/packages/cli/src/test-utils/AppRig.test.tsx @@ -5,6 +5,7 @@ */ import { describe, it, afterEach, expect } from 'vitest'; +import { act } from 'react'; import { AppRig } from './AppRig.js'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -68,7 +69,11 @@ describe('AppRig', () => { ); rig = new AppRig({ fakeResponsesPath }); await rig.initialize(); - rig.render(); + await act(async () => { + rig!.render(); + // Allow async initializations (like banners) to settle within the act boundary + await new Promise((resolve) => setTimeout(resolve, 0)); + }); // Wait for initial render await rig.waitForIdle(); diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index c4a9ad515c..a0884ee024 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -501,7 +501,7 @@ export class AppRig { get lastFrame() { if (!this.renderResult) return ''; - return stripAnsi(this.renderResult.lastFrame() || ''); + return stripAnsi(this.renderResult.lastFrame({ allowEmpty: true }) || ''); } getStaticOutput() { diff --git a/packages/cli/src/test-utils/async.ts b/packages/cli/src/test-utils/async.ts index 690f0e0397..3069c3f41a 100644 --- a/packages/cli/src/test-utils/async.ts +++ b/packages/cli/src/test-utils/async.ts @@ -13,14 +13,14 @@ import { vi } from 'vitest'; // The version of waitFor from vitest is still fine to use if you aren't waiting // for React state updates. export async function waitFor( - assertion: () => void, + assertion: () => void | Promise, { timeout = 2000, interval = 50 } = {}, ): Promise { const startTime = Date.now(); while (true) { try { - assertion(); + await assertion(); return; } catch (error) { if (Date.now() - startTime > timeout) { diff --git a/packages/cli/src/test-utils/render.test.tsx b/packages/cli/src/test-utils/render.test.tsx index fcf6f5ec0a..7172a99119 100644 --- a/packages/cli/src/test-utils/render.test.tsx +++ b/packages/cli/src/test-utils/render.test.tsx @@ -5,36 +5,48 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, act } from 'react'; import { Text } from 'ink'; import { renderHook, render } from './render.js'; import { waitFor } from './async.js'; describe('render', () => { - it('should render a component', () => { - const { lastFrame } = render(Hello World); - expect(lastFrame()).toBe('Hello World'); + it('should render a component', async () => { + const { lastFrame, waitUntilReady, unmount } = render( + Hello World, + ); + await waitUntilReady(); + expect(lastFrame()).toBe('Hello World\n'); + unmount(); }); - it('should support rerender', () => { - const { lastFrame, rerender } = render(Hello); - expect(lastFrame()).toBe('Hello'); + it('should support rerender', async () => { + const { lastFrame, rerender, waitUntilReady, unmount } = render( + Hello, + ); + await waitUntilReady(); + expect(lastFrame()).toBe('Hello\n'); - rerender(World); - expect(lastFrame()).toBe('World'); + await act(async () => { + rerender(World); + }); + await waitUntilReady(); + expect(lastFrame()).toBe('World\n'); + unmount(); }); - it('should support unmount', () => { - const cleanup = vi.fn(); + it('should support unmount', async () => { + const cleanupMock = vi.fn(); function TestComponent() { - useEffect(() => cleanup, []); + useEffect(() => cleanupMock, []); return Hello; } - const { unmount } = render(); + const { unmount, waitUntilReady } = render(); + await waitUntilReady(); unmount(); - expect(cleanup).toHaveBeenCalled(); + expect(cleanupMock).toHaveBeenCalled(); }); }); @@ -48,49 +60,74 @@ describe('renderHook', () => { return { count, value }; }; - const { result, rerender } = renderHook(useTestHook, { - initialProps: { value: 1 }, - }); + const { result, rerender, waitUntilReady, unmount } = renderHook( + useTestHook, + { + initialProps: { value: 1 }, + }, + ); + await waitUntilReady(); expect(result.current.value).toBe(1); await waitFor(() => expect(result.current.count).toBe(1)); // Rerender with new props - rerender({ value: 2 }); + await act(async () => { + rerender({ value: 2 }); + }); + await waitUntilReady(); expect(result.current.value).toBe(2); await 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(); + await act(async () => { + rerender(); + }); + await waitUntilReady(); expect(result.current.value).toBe(2); // Count should not increase because value didn't change await waitFor(() => expect(result.current.count).toBe(2)); + unmount(); }); - it('should handle initial render without props', () => { + it('should handle initial render without props', async () => { const useTestHook = () => { const [count, setCount] = useState(0); return { count, increment: () => setCount((c) => c + 1) }; }; - const { result, rerender } = renderHook(useTestHook); + const { result, rerender, waitUntilReady, unmount } = + renderHook(useTestHook); + await waitUntilReady(); expect(result.current.count).toBe(0); - rerender(); + await act(async () => { + rerender(); + }); + await waitUntilReady(); expect(result.current.count).toBe(0); + unmount(); }); - it('should update props if undefined is passed explicitly', () => { + it('should update props if undefined is passed explicitly', async () => { const useTestHook = (val: string | undefined) => val; - const { result, rerender } = renderHook(useTestHook, { - initialProps: 'initial', - }); + const { result, rerender, waitUntilReady, unmount } = renderHook( + useTestHook, + { + initialProps: 'initial' as string | undefined, + }, + ); + await waitUntilReady(); expect(result.current).toBe('initial'); - rerender(undefined); + await act(async () => { + rerender(undefined); + }); + await waitUntilReady(); expect(result.current).toBeUndefined(); + unmount(); }); }); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index f043fade8d..ac6862e893 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -4,10 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render as inkRender } from 'ink-testing-library'; +import { render as inkRenderDirect, type Instance as InkInstance } from 'ink'; +import { EventEmitter } from 'node:events'; import { Box } from 'ink'; import type React from 'react'; +import { Terminal } from '@xterm/headless'; import { vi } from 'vitest'; +import stripAnsi from 'strip-ansi'; import { act, useState } from 'react'; import os from 'node:os'; import { LoadedSettings } from '../config/settings.js'; @@ -40,6 +43,15 @@ import { pickDefaultThemeName } from '../ui/themes/theme.js'; export const persistentStateMock = new FakePersistentState(); +if (process.env['NODE_ENV'] === 'test') { + // We mock NODE_ENV to development during tests that use render.tsx + // so that animations (which check process.env.NODE_ENV !== 'test') + // are actually tested. We mutate process.env directly here because + // vi.stubEnv() is cleared by vi.unstubAllEnvs() in test-setup.ts + // after each test. + process.env['NODE_ENV'] = 'development'; +} + vi.mock('../utils/persistentState.js', () => ({ persistentState: persistentStateMock, })); @@ -50,51 +62,356 @@ vi.mock('../ui/utils/terminalUtils.js', () => ({ isITerm2: vi.fn(() => false), })); -// Wrapper around ink-testing-library's render that ensures act() is called +type TerminalState = { + terminal: Terminal; + cols: number; + rows: number; +}; + +class XtermStdout extends EventEmitter { + private state: TerminalState; + private pendingWrites = 0; + private renderCount = 0; + private queue: { promise: Promise }; + isTTY = true; + + private lastRenderOutput: string | undefined = undefined; + private lastRenderStaticContent: string | undefined = undefined; + + constructor(state: TerminalState, queue: { promise: Promise }) { + super(); + this.state = state; + this.queue = queue; + } + + get columns() { + return this.state.terminal.cols; + } + + get rows() { + return this.state.terminal.rows; + } + + get frames(): string[] { + return []; + } + + write = (data: string) => { + this.pendingWrites++; + this.queue.promise = this.queue.promise.then(async () => { + await new Promise((resolve) => + this.state.terminal.write(data, resolve), + ); + this.pendingWrites--; + }); + }; + + clear = () => { + this.state.terminal.reset(); + this.lastRenderOutput = undefined; + this.lastRenderStaticContent = undefined; + }; + + dispose = () => { + this.state.terminal.dispose(); + }; + + onRender = (staticContent: string, output: string) => { + this.renderCount++; + this.lastRenderStaticContent = staticContent; + this.lastRenderOutput = output; + this.emit('render'); + }; + + lastFrame = (options: { allowEmpty?: boolean } = {}) => { + let result: string; + // On Windows, xterm.js headless can sometimes have timing or rendering issues + // that lead to duplicated content or incorrect buffer state in tests. + // As a fallback, we can trust the raw output Ink provided during onRender. + if (os.platform() === 'win32') { + result = + (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''); + } else { + const buffer = this.state.terminal.buffer.active; + const allLines: string[] = []; + for (let i = 0; i < buffer.length; i++) { + allLines.push(buffer.getLine(i)?.translateToString(true) ?? ''); + } + + const trimmed = [...allLines]; + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '') { + trimmed.pop(); + } + result = trimmed.join('\n'); + } + + // Normalize for cross-platform snapshot stability: + // Normalize any \r\n to \n + const normalized = result.replace(/\r\n/g, '\n'); + + if (normalized === '' && !options.allowEmpty) { + throw new Error( + 'lastFrame() returned an empty string. If this is intentional, use lastFrame({ allowEmpty: true }). ' + + 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.', + ); + } + return normalized === '' ? normalized : normalized + '\n'; + }; + + async waitUntilReady() { + const startRenderCount = this.renderCount; + if (!vi.isFakeTimers()) { + // Give Ink a chance to start its rendering loop + await new Promise((resolve) => setImmediate(resolve)); + } + await act(async () => { + if (vi.isFakeTimers()) { + await vi.advanceTimersByTimeAsync(50); + } else { + // Wait for at least one render to be called if we haven't rendered yet or since start of this call, + // but don't wait forever as some renders might be synchronous or skipped. + if (this.renderCount === startRenderCount) { + const renderPromise = new Promise((resolve) => + this.once('render', resolve), + ); + const timeoutPromise = new Promise((resolve) => + setTimeout(resolve, 50), + ); + await Promise.race([renderPromise, timeoutPromise]); + } + } + }); + + let attempts = 0; + const maxAttempts = 50; + + let lastCurrent = ''; + let lastExpected = ''; + + while (attempts < maxAttempts) { + // Ensure all pending writes to the terminal are processed. + await this.queue.promise; + + const currentFrame = stripAnsi( + this.lastFrame({ allowEmpty: true }), + ).trim(); + const expectedFrame = stripAnsi( + (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''), + ) + .trim() + .replace(/\r\n/g, '\n'); + + lastCurrent = currentFrame; + lastExpected = expectedFrame; + + const isMatch = () => { + if (expectedFrame === '...') { + return currentFrame !== ''; + } + + // If both are empty, it's a match. + // We consider undefined lastRenderOutput as effectively empty for this check + // to support hook testing where Ink may skip rendering completely. + if ( + (this.lastRenderOutput === undefined || expectedFrame === '') && + currentFrame === '' + ) { + return true; + } + + if (this.lastRenderOutput === undefined) { + return false; + } + + // If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match. + if (expectedFrame === '' || currentFrame === '') { + return false; + } + + // Check if the current frame contains the expected content. + // We use includes because xterm might have some formatting or + // extra whitespace that Ink doesn't account for in its raw output metrics. + return currentFrame.includes(expectedFrame); + }; + + if (this.pendingWrites === 0 && isMatch()) { + return; + } + + attempts++; + await act(async () => { + if (vi.isFakeTimers()) { + await vi.advanceTimersByTimeAsync(10); + } else { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }); + } + + throw new Error( + `waitUntilReady() timed out after ${maxAttempts} attempts.\n` + + `Expected content (stripped ANSI):\n"${lastExpected}"\n` + + `Actual content (stripped ANSI):\n"${lastCurrent}"\n` + + `Pending writes: ${this.pendingWrites}\n` + + `Render count: ${this.renderCount}`, + ); + } +} + +class XtermStderr extends EventEmitter { + private state: TerminalState; + private pendingWrites = 0; + private queue: { promise: Promise }; + isTTY = true; + + constructor(state: TerminalState, queue: { promise: Promise }) { + super(); + this.state = state; + this.queue = queue; + } + + write = (data: string) => { + this.pendingWrites++; + this.queue.promise = this.queue.promise.then(async () => { + await new Promise((resolve) => + this.state.terminal.write(data, resolve), + ); + this.pendingWrites--; + }); + }; + + dispose = () => { + this.state.terminal.dispose(); + }; + + lastFrame = () => ''; +} + +class XtermStdin extends EventEmitter { + isTTY = true; + data: string | null = null; + constructor(options: { isTTY?: boolean } = {}) { + super(); + this.isTTY = options.isTTY ?? true; + } + + write = (data: string) => { + this.data = data; + this.emit('readable'); + this.emit('data', data); + }; + + setEncoding() {} + setRawMode() {} + resume() {} + pause() {} + ref() {} + unref() {} + + read = () => { + const { data } = this; + this.data = null; + return data; + }; +} + +export type RenderInstance = { + rerender: (tree: React.ReactElement) => void; + unmount: () => void; + cleanup: () => void; + stdout: XtermStdout; + stderr: XtermStderr; + stdin: XtermStdin; + frames: string[]; + lastFrame: (options?: { allowEmpty?: boolean }) => string; + terminal: Terminal; + waitUntilReady: () => Promise; +}; + +const instances: InkInstance[] = []; + +// Wrapper around ink's render that ensures act() is called and uses Xterm for output export const render = ( tree: React.ReactElement, terminalWidth?: number, -): ReturnType => { - let renderResult: ReturnType = - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - undefined as unknown as ReturnType; - act(() => { - renderResult = inkRender(tree); +): RenderInstance => { + const cols = terminalWidth ?? 100; + const rows = 40; + const terminal = new Terminal({ + cols, + rows, + allowProposedApi: true, + convertEol: true, }); - if (terminalWidth !== undefined && renderResult?.stdout) { - // Override the columns getter on the stdout instance provided by ink-testing-library - Object.defineProperty(renderResult.stdout, 'columns', { - get: () => terminalWidth, - configurable: true, - }); + const state: TerminalState = { + terminal, + cols, + rows, + }; + const writeQueue = { promise: Promise.resolve() }; + const stdout = new XtermStdout(state, writeQueue); + const stderr = new XtermStderr(state, writeQueue); + const stdin = new XtermStdin(); - // Trigger a rerender so Ink can pick up the new terminal width - act(() => { - renderResult.rerender(tree); + let instance!: InkInstance; + stdout.clear(); + act(() => { + instance = inkRenderDirect(tree, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stdout: stdout as unknown as NodeJS.WriteStream, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stderr: stderr as unknown as NodeJS.WriteStream, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + stdin: stdin as unknown as NodeJS.ReadStream, + debug: false, + exitOnCtrlC: false, + patchConsole: false, + onRender: (metrics: { output: string; staticOutput?: string }) => { + stdout.onRender(metrics.staticOutput ?? '', metrics.output); + }, }); - } + }); - const originalUnmount = renderResult.unmount; - const originalRerender = renderResult.rerender; + instances.push(instance); return { - ...renderResult, - unmount: () => { - act(() => { - originalUnmount(); - }); - }, rerender: (newTree: React.ReactElement) => { act(() => { - originalRerender(newTree); + stdout.clear(); + instance.rerender(newTree); }); }, + unmount: () => { + act(() => { + instance.unmount(); + }); + stdout.dispose(); + stderr.dispose(); + }, + cleanup: instance.cleanup, + stdout, + stderr, + stdin, + frames: stdout.frames, + lastFrame: stdout.lastFrame, + terminal: state.terminal, + waitUntilReady: () => stdout.waitUntilReady(), }; }; +export const cleanup = () => { + for (const instance of instances) { + act(() => { + instance.unmount(); + }); + instance.cleanup(); + } + instances.length = 0; +}; + export const simulateClick = async ( - stdin: ReturnType['stdin'], + stdin: XtermStdin, col: number, row: number, button: 0 | 1 | 2 = 0, // 0 for left, 1 for middle, 2 for right @@ -151,7 +468,7 @@ export const mockSettings = new LoadedSettings( const baseMockUiState = { renderMarkdown: true, streamingState: StreamingState.Idle, - terminalWidth: 120, + terminalWidth: 100, terminalHeight: 40, currentModel: 'gemini-pro', terminalBackgroundColor: 'black', @@ -258,7 +575,13 @@ export const renderWithProviders = ( }; appState?: AppState; } = {}, -): ReturnType & { simulateClick: typeof simulateClick } => { +): RenderInstance & { + simulateClick: ( + col: number, + row: number, + button?: 0 | 1 | 2, + ) => Promise; +} => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, @@ -377,7 +700,11 @@ export const renderWithProviders = ( terminalWidth, ); - return { ...renderResult, simulateClick }; + return { + ...renderResult, + simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => + simulateClick(renderResult.stdin, col, row, button), + }; }; export function renderHook( @@ -390,6 +717,7 @@ export function renderHook( result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; + waitUntilReady: () => Promise; } { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; @@ -411,6 +739,7 @@ export function renderHook( let inkRerender: (tree: React.ReactElement) => void = () => {}; let unmount: () => void = () => {}; + let waitUntilReady: () => Promise = async () => {}; act(() => { const renderResult = render( @@ -420,6 +749,7 @@ export function renderHook( ); inkRerender = renderResult.rerender; unmount = renderResult.unmount; + waitUntilReady = renderResult.waitUntilReady; }); function rerender(props?: Props) { @@ -436,7 +766,7 @@ export function renderHook( }); } - return { result, rerender, unmount }; + return { result, rerender, unmount, waitUntilReady }; } export function renderHookWithProviders( @@ -457,6 +787,7 @@ export function renderHookWithProviders( result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; + waitUntilReady: () => Promise; } { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; @@ -506,5 +837,6 @@ export function renderHookWithProviders( renderResult.unmount(); }); }, + waitUntilReady: () => renderResult.waitUntilReady(), }; } diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 2e40d35260..d96bfe3071 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -92,32 +92,42 @@ describe('App', () => { backgroundShells: new Map(), }; - it('should render main content and composer when not quitting', () => { - const { lastFrame } = renderWithProviders(, { - uiState: mockUIState, - useAlternateBuffer: false, - }); + it('should render main content and composer when not quitting', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: mockUIState, + useAlternateBuffer: false, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Composer'); + unmount(); }); - it('should render quitting display when quittingMessages is set', () => { + it('should render quitting display when quittingMessages is set', async () => { const quittingUIState = { ...mockUIState, quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; - const { lastFrame } = renderWithProviders(, { - uiState: quittingUIState, - useAlternateBuffer: false, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: quittingUIState, + useAlternateBuffer: false, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Quitting...'); + unmount(); }); - it('should render full history in alternate buffer mode when quittingMessages is set', () => { + it('should render full history in alternate buffer mode when quittingMessages is set', async () => { const quittingUIState = { ...mockUIState, quittingMessages: [{ id: 1, type: 'user', text: 'test' }], @@ -125,28 +135,38 @@ describe('App', () => { pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; - const { lastFrame } = renderWithProviders(, { - uiState: quittingUIState, - useAlternateBuffer: true, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: quittingUIState, + useAlternateBuffer: true, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('HistoryItemDisplay'); expect(lastFrame()).toContain('Quitting...'); + unmount(); }); - it('should render dialog manager when dialogs are visible', () => { + it('should render dialog manager when dialogs are visible', async () => { const dialogUIState = { ...mockUIState, dialogsVisible: true, } as UIState; - const { lastFrame } = renderWithProviders(, { - uiState: dialogUIState, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: dialogUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('DialogManager'); + unmount(); }); it.each([ @@ -154,47 +174,62 @@ describe('App', () => { { key: 'D', stateKey: 'ctrlDPressedOnce' }, ])( 'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true', - ({ key, stateKey }) => { + async ({ key, stateKey }) => { const uiState = { ...mockUIState, dialogsVisible: true, [stateKey]: true, } as UIState; - const { lastFrame } = renderWithProviders(, { - uiState, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`); + unmount(); }, ); - it('should render ScreenReaderAppLayout when screen reader is enabled', () => { + it('should render ScreenReaderAppLayout when screen reader is enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame } = renderWithProviders(, { - uiState: mockUIState, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: mockUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Footer'); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Composer'); + unmount(); }); - it('should render DefaultAppLayout when screen reader is not enabled', () => { + it('should render DefaultAppLayout when screen reader is not enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame } = renderWithProviders(, { - uiState: mockUIState, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: mockUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Composer'); + unmount(); }); - it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', () => { + it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); const toolCalls = [ @@ -234,44 +269,64 @@ describe('App', () => { vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); - const { lastFrame } = renderWithProviders(, { - uiState: stateWithConfirmingTool, - config: configWithExperiment, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: stateWithConfirmingTool, + config: configWithExperiment, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue expect(lastFrame()).toContain('Composer'); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); describe('Snapshots', () => { - it('renders default layout correctly', () => { + it('renders default layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame } = renderWithProviders(, { - uiState: mockUIState, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: mockUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); - it('renders screen reader layout correctly', () => { + it('renders screen reader layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame } = renderWithProviders(, { - uiState: mockUIState, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: mockUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); - it('renders with dialogs visible', () => { + it('renders with dialogs visible', async () => { const dialogUIState = { ...mockUIState, dialogsVisible: true, } as UIState; - const { lastFrame } = renderWithProviders(, { - uiState: dialogUIState, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: dialogUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 1ec43eae48..5554ecb58a 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -14,9 +14,8 @@ import { type Mock, type MockedObject, } from 'vitest'; -import { render, persistentStateMock } from '../test-utils/render.js'; +import { render, cleanup, persistentStateMock } from '../test-utils/render.js'; import { waitFor } from '../test-utils/async.js'; -import { cleanup } from 'ink-testing-library'; import { act, useContext, type ReactElement } from 'react'; import { AppContainer } from './AppContainer.js'; import { SettingsContext } from './contexts/SettingsContext.js'; diff --git a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx index 1d39828b14..52d00550ea 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx @@ -11,8 +11,6 @@ import { IdeIntegrationNudge } from './IdeIntegrationNudge.js'; import { KeypressProvider } from './contexts/KeypressContext.js'; import { debugLogger } from '@google/gemini-cli-core'; -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - // Mock debugLogger vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -56,128 +54,129 @@ describe('IdeIntegrationNudge', () => { }); it('renders correctly with default options', async () => { - const { lastFrame } = render( + const { lastFrame, waitUntilReady, unmount } = render( , ); - await act(async () => { - await delay(100); - }); + await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?'); expect(frame).toContain('Yes'); expect(frame).toContain('No (esc)'); expect(frame).toContain("No, don't ask again"); + unmount(); }); it('handles "Yes" selection', async () => { const onComplete = vi.fn(); - const { stdin } = render( + const { stdin, waitUntilReady, unmount } = render( , ); - await act(async () => { - await delay(100); - }); + await waitUntilReady(); // "Yes" is the first option and selected by default usually. await act(async () => { stdin.write('\r'); - await delay(100); }); + await waitUntilReady(); expect(onComplete).toHaveBeenCalledWith({ userSelection: 'yes', isExtensionPreInstalled: false, }); + unmount(); }); it('handles "No" selection', async () => { const onComplete = vi.fn(); - const { stdin } = render( + const { stdin, waitUntilReady, unmount } = render( , ); - await act(async () => { - await delay(100); - }); + await waitUntilReady(); // Navigate down to "No (esc)" await act(async () => { stdin.write('\u001B[B'); // Down arrow - await delay(100); }); + await waitUntilReady(); + await act(async () => { stdin.write('\r'); // Enter - await delay(100); }); + await waitUntilReady(); expect(onComplete).toHaveBeenCalledWith({ userSelection: 'no', isExtensionPreInstalled: false, }); + unmount(); }); it('handles "Dismiss" selection', async () => { const onComplete = vi.fn(); - const { stdin } = render( + const { stdin, waitUntilReady, unmount } = render( , ); - await act(async () => { - await delay(100); - }); + await waitUntilReady(); // Navigate down to "No, don't ask again" await act(async () => { stdin.write('\u001B[B'); // Down arrow - await delay(100); }); + await waitUntilReady(); + await act(async () => { stdin.write('\u001B[B'); // Down arrow - await delay(100); }); + await waitUntilReady(); + await act(async () => { stdin.write('\r'); // Enter - await delay(100); }); + await waitUntilReady(); expect(onComplete).toHaveBeenCalledWith({ userSelection: 'dismiss', isExtensionPreInstalled: false, }); + unmount(); }); it('handles Escape key press', async () => { const onComplete = vi.fn(); - const { stdin } = render( + const { stdin, waitUntilReady, unmount } = render( , ); - await act(async () => { - await delay(100); - }); + await waitUntilReady(); // Press Escape await act(async () => { stdin.write('\u001B'); - await delay(100); + }); + // Escape key has a timeout in KeypressContext, so we need to wrap waitUntilReady in act + await act(async () => { + await waitUntilReady(); }); expect(onComplete).toHaveBeenCalledWith({ userSelection: 'no', isExtensionPreInstalled: false, }); + unmount(); }); it('displays correct text and handles selection when extension is pre-installed', async () => { @@ -185,15 +184,13 @@ describe('IdeIntegrationNudge', () => { vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp'); const onComplete = vi.fn(); - const { lastFrame, stdin } = render( + const { lastFrame, stdin, waitUntilReady, unmount } = render( , ); - await act(async () => { - await delay(100); - }); + await waitUntilReady(); const frame = lastFrame(); @@ -205,12 +202,13 @@ describe('IdeIntegrationNudge', () => { // Select "Yes" await act(async () => { stdin.write('\r'); - await delay(100); }); + await waitUntilReady(); expect(onComplete).toHaveBeenCalledWith({ userSelection: 'yes', isExtensionPreInstalled: true, }); + unmount(); }); }); diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index f8451ee353..d95adcda95 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -61,7 +61,8 @@ Tips for getting started: 2. Be specific for the best results. 3. Create GEMINI.md files to customize your interactions with Gemini. 4. /help for more information. -Composer" +Composer +" `; exports[`App > Snapshots > renders with dialogs visible 1`] = ` @@ -124,19 +125,19 @@ Tips for getting started: 3. Create GEMINI.md files to customize your interactions with Gemini. 4. /help for more information. HistoryItemDisplay -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Action Required │ -│ │ -│ ? ls list directory │ -│ │ -│ ls │ -│ Allow execution of: 'ls'? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. No, suggest changes (esc) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Action Required │ +│ │ +│ ? ls list directory │ +│ │ +│ ls │ +│ Allow execution of: 'ls'? │ +│ │ +│ ● 1. Allow once │ +│ 2. Allow for this session │ +│ 3. No, suggest changes (esc) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index 6de0f41403..86d3204b84 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -65,21 +65,24 @@ describe('ApiAuthDialog', () => { mockedUseTextBuffer.mockReturnValue(mockBuffer); }); - it('renders correctly', () => { - const { lastFrame } = render( + it('renders correctly', async () => { + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); - it('renders with a defaultValue', () => { - render( + it('renders with a defaultValue', async () => { + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(mockedUseTextBuffer).toHaveBeenCalledWith( expect.objectContaining({ initialText: 'test-key', @@ -88,6 +91,7 @@ describe('ApiAuthDialog', () => { }), }), ); + unmount(); }); it.each([ @@ -100,9 +104,12 @@ describe('ApiAuthDialog', () => { { keyName: 'escape', sequence: '\u001b', expectedCall: onCancel, args: [] }, ])( 'calls $expectedCall.name when $keyName is pressed', - ({ keyName, sequence, expectedCall, args }) => { + async ({ keyName, sequence, expectedCall, args }) => { mockBuffer.text = 'submitted-key'; // Set for the onSubmit case - render(); + const { waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) // calls[1] is the TextInput's useKeypress (typing handler) const keypressHandler = mockedUseKeypress.mock.calls[1][0]; @@ -117,23 +124,29 @@ describe('ApiAuthDialog', () => { }); expect(expectedCall).toHaveBeenCalledWith(...args); + unmount(); }, ); - it('displays an error message', () => { - const { lastFrame } = render( + it('displays an error message', async () => { + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Invalid API Key'); + unmount(); }); it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { - render(); + const { waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); // Call 0 is ApiAuthDialog (isActive: true) // Call 1 is TextInput (isActive: true, priority: true) const keypressHandler = mockedUseKeypress.mock.calls[0][0]; @@ -149,5 +162,6 @@ describe('ApiAuthDialog', () => { expect(clearApiKey).toHaveBeenCalled(); expect(mockBuffer.setText).toHaveBeenCalledWith(''); }); + unmount(); }); }); diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 8db4cc99ff..c157a6a40d 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -139,11 +139,14 @@ describe('AuthDialog', () => { }, ])( 'correctly shows/hides COMPUTE_ADC options $desc', - ({ env, shouldContain, shouldNotContain }) => { + async ({ env, shouldContain, shouldNotContain }) => { for (const [key, value] of Object.entries(env)) { vi.stubEnv(key, value as string); } - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; for (const item of shouldContain) { expect(items).toContainEqual(item); @@ -151,23 +154,32 @@ describe('AuthDialog', () => { for (const item of shouldNotContain) { expect(items).not.toContainEqual(item); } + unmount(); }, ); }); - it('filters auth types when enforcedType is set', () => { + it('filters auth types when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(1); expect(items[0].value).toBe(AuthType.USE_GEMINI); + unmount(); }); - it('sets initial index to 0 when enforcedType is set', () => { + it('sets initial index to 0 when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(initialIndex).toBe(0); + unmount(); }); describe('Initial Auth Type Selection', () => { @@ -199,18 +211,25 @@ describe('AuthDialog', () => { expected: AuthType.LOGIN_WITH_GOOGLE, desc: 'defaults to Login with Google', }, - ])('selects initial auth type $desc', ({ setup, expected }) => { + ])('selects initial auth type $desc', async ({ setup, expected }) => { setup(); - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(items[initialIndex].value).toBe(expected); + unmount(); }); }); describe('handleAuthSelect', () => { - it('calls onAuthError if validation fails', () => { + it('calls onAuthError if validation fails', async () => { mockedValidateAuthMethod.mockReturnValue('Invalid method'); - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; handleAuthSelect(AuthType.USE_GEMINI); @@ -221,11 +240,15 @@ describe('AuthDialog', () => { ); expect(props.onAuthError).toHaveBeenCalledWith('Invalid method'); expect(props.settings.setValue).not.toHaveBeenCalled(); + unmount(); }); it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => { mockedValidateAuthMethod.mockReturnValue(null); - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); @@ -233,16 +256,21 @@ describe('AuthDialog', () => { expect(props.setAuthContext).toHaveBeenCalledWith({ requiresRestart: true, }); + unmount(); }); it('sets auth context with empty object for other auth types', async () => { mockedValidateAuthMethod.mockReturnValue(null); - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); expect(props.setAuthContext).toHaveBeenCalledWith({}); + unmount(); }); it('skips API key dialog on initial setup if env var is present', async () => { @@ -250,7 +278,10 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -258,6 +289,7 @@ describe('AuthDialog', () => { expect(props.setAuthState).toHaveBeenCalledWith( AuthState.Unauthenticated, ); + unmount(); }); it('skips API key dialog if env var is present but empty', async () => { @@ -265,7 +297,10 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', ''); // Empty string // props.settings.merged.security.auth.selectedType is undefined here - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -273,6 +308,7 @@ describe('AuthDialog', () => { expect(props.setAuthState).toHaveBeenCalledWith( AuthState.Unauthenticated, ); + unmount(); }); it('shows API key dialog on initial setup if no env var is present', async () => { @@ -280,7 +316,10 @@ describe('AuthDialog', () => { // process.env['GEMINI_API_KEY'] is not set // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -288,6 +327,7 @@ describe('AuthDialog', () => { expect(props.setAuthState).toHaveBeenCalledWith( AuthState.AwaitingApiKeyInput, ); + unmount(); }); it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => { @@ -297,7 +337,10 @@ describe('AuthDialog', () => { props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -305,6 +348,7 @@ describe('AuthDialog', () => { expect(props.setAuthState).toHaveBeenCalledWith( AuthState.Unauthenticated, ); + unmount(); }); it('exits process for Login with Google when browser is suppressed', async () => { @@ -316,7 +360,10 @@ describe('AuthDialog', () => { vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true); mockedValidateAuthMethod.mockReturnValue(null); - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await act(async () => { @@ -330,13 +377,18 @@ describe('AuthDialog', () => { exitSpy.mockRestore(); logSpy.mockRestore(); vi.useRealTimers(); + unmount(); }); }); - it('displays authError when provided', () => { + it('displays authError when provided', async () => { props.authError = 'Something went wrong'; - const { lastFrame } = renderWithProviders(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); expect(lastFrame()).toContain('Something went wrong'); + unmount(); }); describe('useKeypress', () => { @@ -375,31 +427,47 @@ describe('AuthDialog', () => { expect(p.settings.setValue).not.toHaveBeenCalled(); }, }, - ])('$desc', ({ setup, expectations }) => { + ])('$desc', async ({ setup, expectations }) => { setup(); - renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ name: 'escape' }); expectations(props); + unmount(); }); }); describe('Snapshots', () => { - it('renders correctly with default props', () => { - const { lastFrame } = renderWithProviders(); + it('renders correctly with default props', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); - it('renders correctly with auth error', () => { + it('renders correctly with auth error', async () => { props.authError = 'Something went wrong'; - const { lastFrame } = renderWithProviders(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); - it('renders correctly with enforced auth type', () => { + it('renders correctly with enforced auth type', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { lastFrame } = renderWithProviders(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); }); diff --git a/packages/cli/src/ui/auth/AuthInProgress.test.tsx b/packages/cli/src/ui/auth/AuthInProgress.test.tsx index 4c5f0490c2..7f279a1067 100644 --- a/packages/cli/src/ui/auth/AuthInProgress.test.tsx +++ b/packages/cli/src/ui/auth/AuthInProgress.test.tsx @@ -54,48 +54,80 @@ describe('AuthInProgress', () => { vi.useRealTimers(); }); - it('renders initial state with spinner', () => { - const { lastFrame } = render(); + it('renders initial state with spinner', async () => { + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); expect(lastFrame()).toContain('[Spinner] Waiting for auth...'); expect(lastFrame()).toContain('Press ESC or CTRL+C to cancel'); + unmount(); }); - it('calls onTimeout when ESC is pressed', () => { - render(); + it('calls onTimeout when ESC is pressed', async () => { + const { waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; - keypressHandler({ name: 'escape' } as unknown as Key); + await act(async () => { + keypressHandler({ name: 'escape' } as unknown as Key); + }); + // Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act + await act(async () => { + await waitUntilReady(); + }); + expect(onTimeout).toHaveBeenCalled(); + unmount(); }); - it('calls onTimeout when Ctrl+C is pressed', () => { - render(); + it('calls onTimeout when Ctrl+C is pressed', async () => { + const { waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; - keypressHandler({ name: 'c', ctrl: true } as unknown as Key); + await act(async () => { + keypressHandler({ name: 'c', ctrl: true } as unknown as Key); + }); + await waitUntilReady(); + expect(onTimeout).toHaveBeenCalled(); + unmount(); }); it('calls onTimeout and shows timeout message after 3 minutes', async () => { - const { lastFrame } = render(); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); await act(async () => { vi.advanceTimersByTime(180000); }); + await waitUntilReady(); expect(onTimeout).toHaveBeenCalled(); - await vi.waitUntil( - () => lastFrame()?.includes('Authentication timed out'), - { timeout: 1000 }, - ); + expect(lastFrame()).toContain('Authentication timed out'); + unmount(); }); - it('clears timer on unmount', () => { - const { unmount } = render(); - act(() => { + it('clears timer on unmount', async () => { + const { waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + + await act(async () => { unmount(); }); - vi.advanceTimersByTime(180000); + + await act(async () => { + vi.advanceTimersByTime(180000); + }); expect(onTimeout).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx index ac0966c111..9079358348 100644 --- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx @@ -40,23 +40,26 @@ describe('LoginWithGoogleRestartDialog', () => { vi.useRealTimers(); }); - it('renders correctly', () => { - const { lastFrame } = render( + it('renders correctly', async () => { + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); - it('calls onDismiss when escape is pressed', () => { - render( + it('calls onDismiss when escape is pressed', async () => { + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ @@ -68,6 +71,7 @@ describe('LoginWithGoogleRestartDialog', () => { }); expect(onDismiss).toHaveBeenCalledTimes(1); + unmount(); }); it.each(['r', 'R'])( @@ -75,12 +79,13 @@ describe('LoginWithGoogleRestartDialog', () => { async (keyName) => { vi.useFakeTimers(); - render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ @@ -98,6 +103,7 @@ describe('LoginWithGoogleRestartDialog', () => { expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); vi.useRealTimers(); + unmount(); }, ); }); diff --git a/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap index 3b89525633..7a2057c80b 100644 --- a/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap +++ b/packages/cli/src/ui/auth/__snapshots__/ApiAuthDialog.test.tsx.snap @@ -14,5 +14,6 @@ exports[`ApiAuthDialog > renders correctly 1`] = ` │ │ │ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; diff --git a/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap index 77ccdd2ec9..1874301cbd 100644 --- a/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap +++ b/packages/cli/src/ui/auth/__snapshots__/AuthDialog.test.tsx.snap @@ -1,57 +1,60 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ │ -│ ? Get started │ -│ │ -│ How would you like to authenticate for this project? │ -│ │ -│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ -│ │ -│ Something went wrong │ -│ │ -│ (Use Enter to select) │ -│ │ -│ Terms of Services and Privacy Notice for Gemini CLI │ -│ │ -│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ ? Get started │ +│ │ +│ How would you like to authenticate for this project? │ +│ │ +│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ +│ │ +│ Something went wrong │ +│ │ +│ (Use Enter to select) │ +│ │ +│ Terms of Services and Privacy Notice for Gemini CLI │ +│ │ +│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ │ -│ ? Get started │ -│ │ -│ How would you like to authenticate for this project? │ -│ │ -│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ -│ │ -│ (Use Enter to select) │ -│ │ -│ Terms of Services and Privacy Notice for Gemini CLI │ -│ │ -│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ ? Get started │ +│ │ +│ How would you like to authenticate for this project? │ +│ │ +│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │ +│ │ +│ (Use Enter to select) │ +│ │ +│ Terms of Services and Privacy Notice for Gemini CLI │ +│ │ +│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ │ -│ ? Get started │ -│ │ -│ How would you like to authenticate for this project? │ -│ │ -│ (selected) Use Gemini API Key │ -│ │ -│ (Use Enter to select) │ -│ │ -│ Terms of Services and Privacy Notice for Gemini CLI │ -│ │ -│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ ? Get started │ +│ │ +│ How would you like to authenticate for this project? │ +│ │ +│ (selected) Use Gemini API Key │ +│ │ +│ (Use Enter to select) │ +│ │ +│ Terms of Services and Privacy Notice for Gemini CLI │ +│ │ +│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; diff --git a/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap index effd559184..20fad6d488 100644 --- a/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap +++ b/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap @@ -4,5 +4,6 @@ exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ You have successfully logged in with Google. Gemini CLI needs to be restarted. Press 'r' to │ │ restart, or 'escape' to choose a different auth method. │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 1e5f395a27..cc862b6c42 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -290,7 +290,7 @@ describe('extensionsCommand', () => { it('should inform user if there are no extensions to update with --all', async () => { mockDispatchExtensionState.mockImplementationOnce( - (action: ExtensionUpdateAction) => { + async (action: ExtensionUpdateAction) => { if (action.type === 'SCHEDULE_UPDATE') { action.payload.onComplete([]); } @@ -306,7 +306,7 @@ describe('extensionsCommand', () => { it('should call setPendingItem and addItem in a finally block on success', async () => { mockDispatchExtensionState.mockImplementationOnce( - (action: ExtensionUpdateAction) => { + async (action: ExtensionUpdateAction) => { if (action.type === 'SCHEDULE_UPDATE') { action.payload.onComplete([ { @@ -357,7 +357,7 @@ describe('extensionsCommand', () => { it('should update a single extension by name', async () => { mockDispatchExtensionState.mockImplementationOnce( - (action: ExtensionUpdateAction) => { + async (action: ExtensionUpdateAction) => { if (action.type === 'SCHEDULE_UPDATE') { action.payload.onComplete([ { @@ -382,7 +382,7 @@ describe('extensionsCommand', () => { it('should update multiple extensions by name', async () => { mockDispatchExtensionState.mockImplementationOnce( - (action: ExtensionUpdateAction) => { + async (action: ExtensionUpdateAction) => { if (action.type === 'SCHEDULE_UPDATE') { action.payload.onComplete([ { diff --git a/packages/cli/src/ui/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx index eab18ad089..b7a615a18f 100644 --- a/packages/cli/src/ui/components/AboutBox.test.tsx +++ b/packages/cli/src/ui/components/AboutBox.test.tsx @@ -24,8 +24,11 @@ describe('AboutBox', () => { ideClient: '', }; - it('renders with required props', () => { - const { lastFrame } = renderWithProviders(); + it('renders with required props', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('About Gemini CLI'); expect(output).toContain('1.0.0'); @@ -34,31 +37,44 @@ describe('AboutBox', () => { expect(output).toContain('default'); expect(output).toContain('macOS'); expect(output).toContain('Logged in with Google'); + unmount(); }); it.each([ ['gcpProject', 'my-project', 'GCP Project'], ['ideClient', 'vscode', 'IDE Client'], ['tier', 'Enterprise', 'Tier'], - ])('renders optional prop %s', (prop, value, label) => { + ])('renders optional prop %s', async (prop, value, label) => { const props = { ...defaultProps, [prop]: value }; - const { lastFrame } = renderWithProviders(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain(label); expect(output).toContain(value); + unmount(); }); - it('renders Auth Method with email when userEmail is provided', () => { + it('renders Auth Method with email when userEmail is provided', async () => { const props = { ...defaultProps, userEmail: 'test@example.com' }; - const { lastFrame } = renderWithProviders(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Logged in with Google (test@example.com)'); + unmount(); }); - it('renders Auth Method correctly when not oauth', () => { + it('renders Auth Method correctly when not oauth', async () => { const props = { ...defaultProps, selectedAuthType: 'api-key' }; - const { lastFrame } = renderWithProviders(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('api-key'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx index 479c6950ff..0cfe00c764 100644 --- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx @@ -16,17 +16,24 @@ describe('AdminSettingsChangedDialog', () => { vi.restoreAllMocks(); }); - it('renders correctly', () => { - const { lastFrame } = renderWithProviders(); + it('renders correctly', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('restarts on "r" key press', async () => { - const { stdin } = renderWithProviders(, { - uiActions: { - handleRestart: handleRestartMock, + const { stdin, waitUntilReady } = renderWithProviders( + , + { + uiActions: { + handleRestart: handleRestartMock, + }, }, - }); + ); + await waitUntilReady(); act(() => { stdin.write('r'); @@ -36,11 +43,15 @@ describe('AdminSettingsChangedDialog', () => { }); it.each(['r', 'R'])('restarts on "%s" key press', async (key) => { - const { stdin } = renderWithProviders(, { - uiActions: { - handleRestart: handleRestartMock, + const { stdin, waitUntilReady } = renderWithProviders( + , + { + uiActions: { + handleRestart: handleRestartMock, + }, }, - }); + ); + await waitUntilReady(); act(() => { stdin.write(key); diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx index 6aa04cfecd..05cd4a47f5 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -118,11 +118,11 @@ describe('AgentConfigDialog', () => { mockOnSave = vi.fn(); }); - const renderDialog = ( + const renderDialog = async ( settings: LoadedSettings, definition: AgentDefinition = createMockAgentDefinition(), - ) => - render( + ) => { + const result = render( { /> , ); + await result.waitUntilReady(); + return result; + }; describe('rendering', () => { - it('should render the dialog with title', () => { + it('should render the dialog with title', async () => { const settings = createMockSettings(); - const { lastFrame } = renderDialog(settings); - + const { lastFrame, unmount } = await renderDialog(settings); expect(lastFrame()).toContain('Configure: Test Agent'); + unmount(); }); - it('should render all configuration fields', () => { + it('should render all configuration fields', async () => { const settings = createMockSettings(); - const { lastFrame } = renderDialog(settings); + const { lastFrame, unmount } = await renderDialog(settings); const frame = lastFrame(); expect(frame).toContain('Enabled'); @@ -156,44 +159,53 @@ describe('AgentConfigDialog', () => { expect(frame).toContain('Max Output Tokens'); expect(frame).toContain('Max Time (minutes)'); expect(frame).toContain('Max Turns'); + unmount(); }); - it('should render scope selector', () => { + it('should render scope selector', async () => { const settings = createMockSettings(); - const { lastFrame } = renderDialog(settings); + const { lastFrame, unmount } = await renderDialog(settings); expect(lastFrame()).toContain('Apply To'); expect(lastFrame()).toContain('User Settings'); expect(lastFrame()).toContain('Workspace Settings'); + unmount(); }); - it('should render help text', () => { + it('should render help text', async () => { const settings = createMockSettings(); - const { lastFrame } = renderDialog(settings); + const { lastFrame, unmount } = await renderDialog(settings); expect(lastFrame()).toContain('Use Enter to select'); expect(lastFrame()).toContain('Tab to change focus'); expect(lastFrame()).toContain('Esc to close'); + unmount(); }); }); describe('keyboard navigation', () => { it('should close dialog on Escape', async () => { const settings = createMockSettings(); - const { stdin } = renderDialog(settings); + const { stdin, waitUntilReady, unmount } = await renderDialog(settings); await act(async () => { stdin.write(TerminalKeys.ESCAPE); }); + // Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act + await act(async () => { + await waitUntilReady(); + }); await waitFor(() => { expect(mockOnClose).toHaveBeenCalled(); }); + unmount(); }); it('should navigate down with arrow key', async () => { const settings = createMockSettings(); - const { lastFrame, stdin } = renderDialog(settings); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderDialog(settings); // Initially first item (Enabled) should be active expect(lastFrame()).toContain('●'); @@ -202,16 +214,19 @@ describe('AgentConfigDialog', () => { await act(async () => { stdin.write(TerminalKeys.DOWN_ARROW); }); + await waitUntilReady(); await waitFor(() => { // Model field should now be highlighted expect(lastFrame()).toContain('Model'); }); + unmount(); }); it('should switch focus with Tab', async () => { const settings = createMockSettings(); - const { lastFrame, stdin } = renderDialog(settings); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderDialog(settings); // Initially settings section is focused expect(lastFrame()).toContain('> Configure: Test Agent'); @@ -220,22 +235,25 @@ describe('AgentConfigDialog', () => { await act(async () => { stdin.write(TerminalKeys.TAB); }); + await waitUntilReady(); await waitFor(() => { expect(lastFrame()).toContain('> Apply To'); }); + unmount(); }); }); describe('boolean toggle', () => { it('should toggle enabled field on Enter', async () => { const settings = createMockSettings(); - const { stdin } = renderDialog(settings); + const { stdin, waitUntilReady, unmount } = await renderDialog(settings); // Press Enter to toggle the first field (Enabled) await act(async () => { stdin.write(TerminalKeys.ENTER); }); + await waitUntilReady(); await waitFor(() => { expect(settings.setValue).toHaveBeenCalledWith( @@ -245,11 +263,12 @@ describe('AgentConfigDialog', () => { ); expect(mockOnSave).toHaveBeenCalled(); }); + unmount(); }); }); describe('default values', () => { - it('should show values from agent definition as defaults', () => { + it('should show values from agent definition as defaults', async () => { const definition = createMockAgentDefinition({ modelConfig: { model: 'gemini-2.0-flash', @@ -263,29 +282,31 @@ describe('AgentConfigDialog', () => { }, }); const settings = createMockSettings(); - const { lastFrame } = renderDialog(settings, definition); + const { lastFrame, unmount } = await renderDialog(settings, definition); const frame = lastFrame(); expect(frame).toContain('gemini-2.0-flash'); expect(frame).toContain('0.7'); expect(frame).toContain('10'); expect(frame).toContain('20'); + unmount(); }); - it('should show experimental agents as disabled by default', () => { + it('should show experimental agents as disabled by default', async () => { const definition = createMockAgentDefinition({ experimental: true, }); const settings = createMockSettings(); - const { lastFrame } = renderDialog(settings, definition); + const { lastFrame, unmount } = await renderDialog(settings, definition); // Experimental agents default to disabled expect(lastFrame()).toContain('false'); + unmount(); }); }); describe('existing overrides', () => { - it('should show existing override values with * indicator', () => { + it('should show existing override values with * indicator', async () => { const settings = createMockSettings({ agents: { overrides: { @@ -298,12 +319,13 @@ describe('AgentConfigDialog', () => { }, }, }); - const { lastFrame } = renderDialog(settings); + const { lastFrame, unmount } = await renderDialog(settings); const frame = lastFrame(); // Should show the overridden values expect(frame).toContain('custom-model'); expect(frame).toContain('false'); + unmount(); }); }); }); diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx index 8b81f1fa42..6e9623a8ff 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -106,9 +106,9 @@ describe('AlternateBufferQuittingDisplay', () => { }, }; - it('renders with active and pending tool messages', () => { + it('renders with active and pending tool messages', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -118,12 +118,14 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_and_pending'); + unmount(); }); - it('renders with empty history and no pending items', () => { + it('renders with empty history and no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -133,12 +135,14 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('empty'); + unmount(); }); - it('renders with history but no pending items', () => { + it('renders with history but no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -148,12 +152,14 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_no_pending'); + unmount(); }); - it('renders with pending items but no history', () => { + it('renders with pending items but no history', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -163,10 +169,12 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_pending_no_history'); + unmount(); }); - it('renders with a tool awaiting confirmation', () => { + it('renders with a tool awaiting confirmation', async () => { persistentStateMock.setData({ tipsShown: 0 }); const pendingHistoryItems: HistoryItemWithoutId[] = [ { @@ -187,7 +195,7 @@ describe('AlternateBufferQuittingDisplay', () => { ], }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -197,20 +205,22 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Action Required (was prompted):'); expect(output).toContain('confirming_tool'); expect(output).toContain('Confirming tool description'); expect(output).toMatchSnapshot('with_confirming_tool'); + unmount(); }); - it('renders with user and gemini messages', () => { + it('renders with user and gemini messages', async () => { persistentStateMock.setData({ tipsShown: 0 }); const history: HistoryItem[] = [ { id: 1, type: 'user', text: 'Hello Gemini' }, { id: 2, type: 'gemini', text: 'Hello User!' }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -220,6 +230,8 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 6f1accf608..770eb9b056 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -22,15 +22,19 @@ const createAnsiToken = (overrides: Partial): AnsiToken => ({ }); describe('', () => { - it('renders a simple AnsiOutput object correctly', () => { + it('renders a simple AnsiOutput object correctly', async () => { const data: AnsiOutput = [ [ createAnsiToken({ text: 'Hello, ' }), createAnsiToken({ text: 'world!' }), ], ]; - const { lastFrame } = render(); - expect(lastFrame()).toBe('Hello, world!'); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toBe('Hello, world!\n'); + unmount(); }); // Note: ink-testing-library doesn't render styles, so we can only check the text. @@ -41,73 +45,89 @@ describe('', () => { { style: { underline: true }, text: 'Underline' }, { style: { dim: true }, text: 'Dim' }, { style: { inverse: true }, text: 'Inverse' }, - ])('correctly applies style $text', ({ style, text }) => { + ])('correctly applies style $text', async ({ style, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...style })]]; - const { lastFrame } = render(); - expect(lastFrame()).toBe(text); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toBe(text + '\n'); + unmount(); }); it.each([ { color: { fg: '#ff0000' }, text: 'Red FG' }, { color: { bg: '#0000ff' }, text: 'Blue BG' }, { color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' }, - ])('correctly applies color $text', ({ color, text }) => { + ])('correctly applies color $text', async ({ color, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...color })]]; - const { lastFrame } = render(); - expect(lastFrame()).toBe(text); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame()).toBe(text + '\n'); + unmount(); }); - it('handles empty lines and empty tokens', () => { + it('handles empty lines and empty tokens', async () => { const data: AnsiOutput = [ [createAnsiToken({ text: 'First line' })], [], [createAnsiToken({ text: 'Third line' })], [createAnsiToken({ text: '' })], ]; - const { lastFrame } = render(); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); const output = lastFrame(); expect(output).toBeDefined(); - const lines = output!.split('\n'); + const lines = output.split('\n'); expect(lines[0].trim()).toBe('First line'); expect(lines[1].trim()).toBe(''); expect(lines[2].trim()).toBe('Third line'); + unmount(); }); - it('respects the availableTerminalHeight prop and slices the lines correctly', () => { + it('respects the availableTerminalHeight prop and slices the lines correctly', async () => { const data: AnsiOutput = [ [createAnsiToken({ text: 'Line 1' })], [createAnsiToken({ text: 'Line 2' })], [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; - const { lastFrame } = render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); expect(output).toContain('Line 3'); expect(output).toContain('Line 4'); + unmount(); }); - it('respects the maxLines prop and slices the lines correctly', () => { + it('respects the maxLines prop and slices the lines correctly', async () => { const data: AnsiOutput = [ [createAnsiToken({ text: 'Line 1' })], [createAnsiToken({ text: 'Line 2' })], [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; - const { lastFrame } = render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); expect(output).toContain('Line 3'); expect(output).toContain('Line 4'); + unmount(); }); - it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', () => { + it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', async () => { const data: AnsiOutput = [ [createAnsiToken({ text: 'Line 1' })], [createAnsiToken({ text: 'Line 2' })], @@ -115,7 +135,7 @@ describe('', () => { [createAnsiToken({ text: 'Line 4' })], ]; // availableTerminalHeight=3, maxLines=2 => show 2 lines - const { lastFrame } = render( + const { lastFrame, waitUntilReady, unmount } = render( ', () => { width={80} />, ); + await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 2'); expect(output).toContain('Line 3'); expect(output).toContain('Line 4'); + unmount(); }); - it('renders a large AnsiOutput object without crashing', () => { + it('renders a large AnsiOutput object without crashing', async () => { const largeData: AnsiOutput = []; for (let i = 0; i < 1000; i++) { largeData.push([createAnsiToken({ text: `Line ${i}` })]); } - const { lastFrame } = render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); // We are just checking that it renders something without crashing. expect(lastFrame()).toBeDefined(); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index b827de6dc9..9bf821febc 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -18,7 +18,7 @@ vi.mock('../utils/terminalSetup.js', () => ({ })); describe('', () => { - it('should render the banner with default text', () => { + it('should render the banner with default text', async () => { const mockConfig = makeFakeConfig(); const uiState = { history: [], @@ -29,20 +29,21 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); unmount(); }); - it('should render the banner with warning text', () => { + it('should render the banner with warning text', async () => { const mockConfig = makeFakeConfig(); const uiState = { history: [], @@ -53,20 +54,21 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).toContain('There are capacity issues'); expect(lastFrame()).toMatchSnapshot(); unmount(); }); - it('should not render the banner when no flags are set', () => { + it('should not render the banner when no flags are set', async () => { const mockConfig = makeFakeConfig(); const uiState = { history: [], @@ -76,20 +78,21 @@ describe('', () => { }, }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).not.toContain('Banner'); expect(lastFrame()).toMatchSnapshot(); unmount(); }); - it('should not render the default banner if shown count is 5 or more', () => { + it('should not render the default banner if shown count is 5 or more', async () => { const mockConfig = makeFakeConfig(); const uiState = { history: [], @@ -108,20 +111,21 @@ describe('', () => { }, }); - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).not.toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); unmount(); }); - it('should increment the version count when default banner is displayed', () => { + it('should increment the version count when default banner is displayed', async () => { const mockConfig = makeFakeConfig(); const uiState = { history: [], @@ -135,10 +139,14 @@ describe('', () => { // and interfering with the expected persistentState.set call. persistentStateMock.setData({ tipsShown: 10 }); - const { unmount } = renderWithProviders(, { - config: mockConfig, - uiState, - }); + const { waitUntilReady, unmount } = renderWithProviders( + , + { + config: mockConfig, + uiState, + }, + ); + await waitUntilReady(); expect(persistentStateMock.set).toHaveBeenCalledWith( 'defaultBannerShownCount', @@ -152,7 +160,7 @@ describe('', () => { unmount(); }); - it('should render banner text with unescaped newlines', () => { + it('should render banner text with unescaped newlines', async () => { const mockConfig = makeFakeConfig(); const uiState = { history: [], @@ -163,19 +171,20 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).not.toContain('First line\\nSecond line'); unmount(); }); - it('should render Tips when tipsShown is less than 10', () => { + it('should render Tips when tipsShown is less than 10', async () => { const mockConfig = makeFakeConfig(); const uiState = { history: [], @@ -188,36 +197,38 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 5 }); - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).toContain('Tips'); expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6); unmount(); }); - it('should NOT render Tips when tipsShown is 10 or more', () => { + it('should NOT render Tips when tipsShown is 10 or more', async () => { const mockConfig = makeFakeConfig(); persistentStateMock.setData({ tipsShown: 10 }); - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { config: mockConfig, }, ); + await waitUntilReady(); expect(lastFrame()).not.toContain('Tips'); unmount(); }); - it('should show tips until they have been shown 10 times (persistence flow)', () => { + it('should show tips until they have been shown 10 times (persistence flow)', async () => { persistentStateMock.setData({ tipsShown: 9 }); const mockConfig = makeFakeConfig(); @@ -235,6 +246,7 @@ describe('', () => { config: mockConfig, uiState, }); + await session1.waitUntilReady(); expect(session1.lastFrame()).toContain('Tips'); expect(persistentStateMock.get('tipsShown')).toBe(10); @@ -244,6 +256,7 @@ describe('', () => { const session2 = renderWithProviders(, { config: mockConfig, }); + await session2.waitUntilReady(); expect(session2.lastFrame()).not.toContain('Tips'); session2.unmount(); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx index cebe0cc75b..4386891c7a 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -10,51 +10,57 @@ import { describe, it, expect } from 'vitest'; import { ApprovalMode } from '@google/gemini-cli-core'; describe('ApprovalModeIndicator', () => { - it('renders correctly for AUTO_EDIT mode', () => { - const { lastFrame } = render( + it('renders correctly for AUTO_EDIT mode', async () => { + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('renders correctly for AUTO_EDIT mode with plan enabled', () => { - const { lastFrame } = render( + it('renders correctly for AUTO_EDIT mode with plan enabled', async () => { + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('renders correctly for PLAN mode', () => { - const { lastFrame } = render( + it('renders correctly for PLAN mode', async () => { + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('renders correctly for YOLO mode', () => { - const { lastFrame } = render( + it('renders correctly for YOLO mode', async () => { + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('renders correctly for DEFAULT mode', () => { - const { lastFrame } = render( + it('renders correctly for DEFAULT mode', async () => { + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('renders correctly for DEFAULT mode with plan enabled', () => { - const { lastFrame } = render( + it('renders correctly for DEFAULT mode with plan enabled', async () => { + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index b03f375f71..ef04e51499 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -46,8 +46,8 @@ describe('AskUserDialog', () => { }, ]; - it('renders question and options', () => { - const { lastFrame } = renderWithProviders( + it('renders question and options', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -125,7 +126,7 @@ describe('AskUserDialog', () => { actions(stdin); - await waitFor(() => { + await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith(expectedSubmit); }); }); @@ -133,7 +134,7 @@ describe('AskUserDialog', () => { it('handles custom option in single select with inline typing', async () => { const onSubmit = vi.fn(); - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { writeKey(stdin, '\x1b[B'); writeKey(stdin, '\x1b[B'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Enter a custom value'); }); @@ -156,14 +158,15 @@ describe('AskUserDialog', () => { writeKey(stdin, char); } - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('API Key'); }); // Press Enter to submit the custom value writeKey(stdin, '\r'); - await waitFor(() => { + await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' }); }); }); @@ -180,7 +183,7 @@ describe('AskUserDialog', () => { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { writeKey(stdin, char); } - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Line 1'); + await waitUntilReady(); expect(lastFrame()).toContain('Line 2'); }); // Press Enter to submit writeKey(stdin, '\r'); - await waitFor(() => { + await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'Line 1\nLine 2' }); }); }); @@ -240,7 +245,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { useAlternateBuffer }, ); - await waitFor(() => { + await waitFor(async () => { if (expectedArrows) { + await waitUntilReady(); expect(lastFrame()).toContain('▲'); + await waitUntilReady(); expect(lastFrame()).toContain('▼'); } else { + await waitUntilReady(); expect(lastFrame()).not.toContain('▲'); + await waitUntilReady(); expect(lastFrame()).not.toContain('▼'); } + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); @@ -266,7 +276,7 @@ describe('AskUserDialog', () => { ); it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { // Type a character without navigating down writeKey(stdin, 'A'); - await waitFor(() => { + await waitFor(async () => { // Should show the custom input with 'A' // Placeholder is hidden when text is present + await waitUntilReady(); expect(lastFrame()).toContain('A'); + await waitUntilReady(); expect(lastFrame()).toContain('3. A'); }); @@ -290,12 +302,13 @@ describe('AskUserDialog', () => { writeKey(stdin, 'P'); writeKey(stdin, 'I'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('API'); }); }); - it('shows progress header for multiple questions', () => { + it('shows progress header for multiple questions', async () => { const multiQuestions: Question[] = [ { question: 'Which database should we use?', @@ -319,7 +332,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('hides progress header for single question', () => { - const { lastFrame } = renderWithProviders( + it('hides progress header for single question', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('shows keyboard hints', () => { - const { lastFrame } = renderWithProviders( + it('shows keyboard hints', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -380,7 +396,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toContain('Which testing framework?'); writeKey(stdin, '\x1b[C'); // Right arrow - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Which CI provider?'); }); writeKey(stdin, '\x1b[D'); // Left arrow - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Which testing framework?'); }); }); @@ -424,7 +443,7 @@ describe('AskUserDialog', () => { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { // Answer first question (should auto-advance) writeKey(stdin, '\r'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Which bundler?'); }); // Navigate back writeKey(stdin, '\x1b[D'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Which package manager?'); }); // Navigate forward writeKey(stdin, '\x1b[C'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Which bundler?'); }); // Answer second question writeKey(stdin, '\r'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Review your answers:'); }); // Submit from Review writeKey(stdin, '\r'); - await waitFor(() => { + await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' }); }); }); - it('shows Review tab in progress header for multiple questions', () => { + it('shows Review tab in progress header for multiple questions', async () => { const multiQuestions: Question[] = [ { question: 'Which framework?', @@ -494,7 +517,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -525,7 +549,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { writeKey(stdin, '\x1b[C'); // Right arrow - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Add documentation?'); }); writeKey(stdin, '\x1b[C'); // Right arrow to Review - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); writeKey(stdin, '\x1b[D'); // Left arrow back - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Add documentation?'); }); }); @@ -572,7 +599,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { writeKey(stdin, '\x1b[C'); writeKey(stdin, '\x1b[C'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); @@ -627,13 +655,13 @@ describe('AskUserDialog', () => { // Submit writeKey(stdin, '\r'); - await waitFor(() => { + await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' }); }); }); describe('Text type questions', () => { - it('renders text input for type: "text"', () => { + it('renders text input for type: "text"', async () => { const textQuestion: Question[] = [ { question: 'What should we name this component?', @@ -643,7 +671,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('shows default placeholder when none provided', () => { + it('shows default placeholder when none provided', async () => { const textQuestion: Question[] = [ { question: 'Enter the database connection string:', @@ -665,7 +694,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -687,7 +717,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { writeKey(stdin, char); } - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('abc'); }); writeKey(stdin, '\x7f'); // Backspace - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('ab'); + await waitUntilReady(); expect(lastFrame()).not.toContain('abc'); }); }); - it('shows correct keyboard hints for text type', () => { + it('shows correct keyboard hints for text type', async () => { const textQuestion: Question[] = [ { question: 'Enter the variable name:', @@ -722,7 +755,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -754,7 +788,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { writeKey(stdin, '\t'); // Use Tab instead of Right arrow when text input is active - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Should it be async?'); }); writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('useAuth'); }); }); @@ -802,7 +838,7 @@ describe('AskUserDialog', () => { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { writeKey(stdin, '\r'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Which styling approach?'); }); writeKey(stdin, '\r'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Review your answers:'); + await waitUntilReady(); expect(lastFrame()).toContain('Name'); + await waitUntilReady(); expect(lastFrame()).toContain('DataTable'); + await waitUntilReady(); expect(lastFrame()).toContain('Style'); + await waitUntilReady(); expect(lastFrame()).toContain('CSS Modules'); }); writeKey(stdin, '\r'); - await waitFor(() => { + await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'DataTable', '1': 'CSS Modules', @@ -864,7 +906,7 @@ describe('AskUserDialog', () => { writeKey(stdin, '\r'); - await waitFor(() => { + await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({}); }); }); @@ -879,7 +921,7 @@ describe('AskUserDialog', () => { ]; const onCancel = vi.fn(); - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { writeKey(stdin, char); } - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('SomeText'); }); // Send Ctrl+C writeKey(stdin, '\x03'); // Ctrl+C - await waitFor(() => { + await waitFor(async () => { // Text should be cleared + await waitUntilReady(); expect(lastFrame()).not.toContain('SomeText'); + await waitUntilReady(); expect(lastFrame()).toContain('>'); }); @@ -926,7 +971,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { // 1. Move to Text Q (Right arrow works for Choice Q) writeKey(stdin, '\x1b[C'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Text Q?'); }); // 2. Type something in Text Q to make isEditingCustomOption true writeKey(stdin, 'a'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('a'); }); @@ -952,18 +999,21 @@ describe('AskUserDialog', () => { // When typing 'a', cursor is at index 1. // We need to move cursor to index 0 first for Left arrow to work for navigation. writeKey(stdin, '\x1b[D'); // Left arrow moves cursor to index 0 - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Text Q?'); }); writeKey(stdin, '\x1b[D'); // Second Left arrow should now trigger navigation - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Choice Q?'); }); // 4. Immediately try Right arrow to go back to Text Q writeKey(stdin, '\x1b[C'); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Text Q?'); }); }); @@ -987,7 +1037,7 @@ describe('AskUserDialog', () => { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { act(() => { stdin.write('\r'); // Select A1 for Q1 -> triggers autoAdvance }); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Question 2?'); }); act(() => { stdin.write('\r'); // Select A2 for Q2 -> triggers autoAdvance to Review }); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toContain('Review your answers:'); }); @@ -1016,7 +1068,7 @@ describe('AskUserDialog', () => { stdin.write('\r'); // Submit from Review }); - await waitFor(() => { + await waitFor(async () => { expect(onSubmit).toHaveBeenCalledWith({ '0': 'A1', '1': 'A2', @@ -1037,7 +1089,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); const frame = lastFrame(); // Plain text should be rendered as bold expect(frame).toContain(chalk.bold('Which option do you prefer?')); @@ -1066,7 +1119,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); const frame = lastFrame(); // Should NOT have double-bold (the whole question bolded AND "this" bolded) // "Is " should not be bold, only "this" should be bold @@ -1098,7 +1152,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); const frame = lastFrame(); // Check for chalk.bold('this') - asterisks should be gone, text should be bold expect(frame).toContain(chalk.bold('this')); @@ -1128,7 +1183,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); const frame = lastFrame(); // Backticks should be removed expect(frame).toContain('npm start'); @@ -1148,7 +1204,7 @@ describe('AskUserDialog', () => { }); }); - it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', () => { + it('uses availableTerminalHeight from UIStateContext if availableHeight prop is missing', async () => { const questions: Question[] = [ { question: 'Choose an option', @@ -1166,7 +1222,7 @@ describe('AskUserDialog', () => { availableTerminalHeight: 5, // Small height to force scroll arrows } as UIState; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { ); // With height 5 and alternate buffer disabled, it should show scroll arrows (▲) + await waitUntilReady(); expect(lastFrame()).toContain('▲'); + await waitUntilReady(); expect(lastFrame()).toContain('▼'); }); - it('does NOT truncate the question when in alternate buffer mode even with small height', () => { + it('does NOT truncate the question when in alternate buffer mode even with small height', async () => { const longQuestion = 'This is a very long question ' + 'with many words '.repeat(10); const questions: Question[] = [ @@ -1200,7 +1258,7 @@ describe('AskUserDialog', () => { availableTerminalHeight: 5, } as UIState; - const { lastFrame } = renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { ); // Should NOT contain the truncation message + await waitUntilReady(); expect(lastFrame()).not.toContain('hidden ...'); // Should contain the full long question (or at least its parts) + await waitUntilReady(); expect(lastFrame()).toContain('This is a very long question'); }); @@ -1234,7 +1294,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { writeKey(stdin, '\x1b[B'); // Down writeKey(stdin, '\x1b[B'); // Down to Other - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); @@ -1267,7 +1328,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { writeKey(stdin, '\x1b[B'); // Down writeKey(stdin, '\x1b[B'); // Down to Other - await waitFor(() => { + await waitFor(async () => { + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx index 8b14c9c41a..4d37de24c3 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -14,8 +14,6 @@ import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js'; import { ScrollProvider } from '../contexts/ScrollProvider.js'; import { Box } from 'ink'; -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - // Mock dependencies const mockDismissBackgroundShell = vi.fn(); const mockSetActiveBackgroundShellPid = vi.fn(); @@ -142,81 +140,84 @@ describe('', () => { }); it('renders the output of the active shell', async () => { - const { lastFrame } = render( + const width = 80; + const { lastFrame, waitUntilReady, unmount } = render( , + width, ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('renders tabs for multiple shells', async () => { - const { lastFrame } = render( + const width = 100; + const { lastFrame, waitUntilReady, unmount } = render( , + width, ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('highlights the focused state', async () => { - const { lastFrame } = render( + const width = 80; + const { lastFrame, waitUntilReady, unmount } = render( , + width, ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('resizes the PTY on mount and when dimensions change', async () => { - const { rerender } = render( + const width = 80; + const { rerender, waitUntilReady, unmount } = render( , + width, ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, @@ -236,143 +237,152 @@ describe('', () => { /> , ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, 96, 27, ); + unmount(); }); it('renders the process list when isListOpenProp is true', async () => { - const { lastFrame } = render( + const width = 80; + const { lastFrame, waitUntilReady, unmount } = render( , + width, ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => { - render( + const width = 80; + const { waitUntilReady, unmount } = render( , + width, ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); // Simulate down arrow to select the second process (handled by RadioButtonSelect) - act(() => { + await act(async () => { simulateKey({ name: 'down' }); }); + await waitUntilReady(); // Simulate Ctrl+L (handled by BackgroundShellDisplay) - act(() => { + await act(async () => { simulateKey({ name: 'l', ctrl: true }); }); + await waitUntilReady(); expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid); expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false); + unmount(); }); it('kills the highlighted process when Ctrl+K is pressed in list view', async () => { - render( + const width = 80; + const { waitUntilReady, unmount } = render( , + width, ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); // Initial state: shell1 (active) is highlighted // Move to shell2 - act(() => { + await act(async () => { simulateKey({ name: 'down' }); }); + await waitUntilReady(); // Press Ctrl+K - act(() => { + await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); + await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid); + unmount(); }); it('kills the active process when Ctrl+K is pressed in output view', async () => { - render( + const width = 80; + const { waitUntilReady, unmount } = render( , + width, ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); - act(() => { + await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); + await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid); + unmount(); }); it('scrolls to active shell when list opens', async () => { // shell2 is active - const { lastFrame } = render( + const width = 80; + const { lastFrame, waitUntilReady, unmount } = render( , + width, ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('keeps exit code status color even when selected', async () => { @@ -387,22 +397,23 @@ describe('', () => { }; mockShells.set(exitedShell.pid, exitedShell); - const { lastFrame } = render( + const width = 80; + const { lastFrame, waitUntilReady, unmount } = render( , + width, ); - await act(async () => { - await delay(0); - }); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/Banner.test.tsx b/packages/cli/src/ui/components/Banner.test.tsx index ec50b25510..46c47b8a71 100644 --- a/packages/cli/src/ui/components/Banner.test.tsx +++ b/packages/cli/src/ui/components/Banner.test.tsx @@ -12,18 +12,22 @@ describe('Banner', () => { it.each([ ['warning mode', true, 'Warning Message'], ['info mode', false, 'Info Message'], - ])('renders in %s', (_, isWarning, text) => { - const { lastFrame } = render( + ])('renders in %s', async (_, isWarning, text) => { + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); - it('handles newlines in text', () => { + it('handles newlines in text', async () => { const text = 'Line 1\\nLine 2'; - const { lastFrame } = render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/Checklist.test.tsx b/packages/cli/src/ui/components/Checklist.test.tsx index ba1f0e4813..442ee0400f 100644 --- a/packages/cli/src/ui/components/Checklist.test.tsx +++ b/packages/cli/src/ui/components/Checklist.test.tsx @@ -17,26 +17,28 @@ describe('', () => { { status: 'cancelled', label: 'Task 4' }, ]; - it('renders nothing when list is empty', () => { - const { lastFrame } = render( + it('renders nothing when list is empty', async () => { + const { lastFrame, waitUntilReady } = render( , ); - expect(lastFrame()).toBe(''); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); }); - it('renders nothing when collapsed and no active items', () => { + it('renders nothing when collapsed and no active items', async () => { const inactiveItems: ChecklistItemData[] = [ { status: 'completed', label: 'Task 1' }, { status: 'cancelled', label: 'Task 2' }, ]; - const { lastFrame } = render( + const { lastFrame, waitUntilReady } = render( , ); - expect(lastFrame()).toBe(''); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); }); - it('renders summary view correctly (collapsed)', () => { - const { lastFrame } = render( + it('renders summary view correctly (collapsed)', async () => { + const { lastFrame, waitUntilReady } = render( ', () => { toggleHint="toggle me" />, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('renders expanded view correctly', () => { - const { lastFrame } = render( + it('renders expanded view correctly', async () => { + const { lastFrame, waitUntilReady } = render( ', () => { toggleHint="toggle me" />, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('renders summary view without in-progress item if none exists', () => { + it('renders summary view without in-progress item if none exists', async () => { const pendingItems: ChecklistItemData[] = [ { status: 'completed', label: 'Task 1' }, { status: 'pending', label: 'Task 2' }, ]; - const { lastFrame } = render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/ChecklistItem.test.tsx b/packages/cli/src/ui/components/ChecklistItem.test.tsx index 7d52f07ae6..0f6c0eb0b0 100644 --- a/packages/cli/src/ui/components/ChecklistItem.test.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.test.tsx @@ -15,36 +15,39 @@ describe('', () => { { status: 'in_progress', label: 'Doing this' }, { status: 'completed', label: 'Done this' }, { status: 'cancelled', label: 'Skipped this' }, - ] as ChecklistItemData[])('renders %s item correctly', (item) => { - const { lastFrame } = render(); + ] as ChecklistItemData[])('renders %s item correctly', async (item) => { + const { lastFrame, waitUntilReady } = render(); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('truncates long text when wrap="truncate"', () => { + it('truncates long text when wrap="truncate"', async () => { const item: ChecklistItemData = { status: 'in_progress', label: 'This is a very long text that should be truncated because the wrap prop is set to truncate', }; - const { lastFrame } = render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('wraps long text by default', () => { + it('wraps long text by default', async () => { const item: ChecklistItemData = { status: 'in_progress', label: 'This is a very long text that should wrap because the default behavior is wrapping', }; - const { lastFrame } = render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/CliSpinner.test.tsx b/packages/cli/src/ui/components/CliSpinner.test.tsx index 9f05df3930..738c487698 100644 --- a/packages/cli/src/ui/components/CliSpinner.test.tsx +++ b/packages/cli/src/ui/components/CliSpinner.test.tsx @@ -15,17 +15,23 @@ describe('', () => { debugState.debugNumAnimatedComponents = 0; }); - it('should increment debugNumAnimatedComponents on mount and decrement on unmount', () => { + it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => { expect(debugState.debugNumAnimatedComponents).toBe(0); - const { unmount } = renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders(); + await waitUntilReady(); expect(debugState.debugNumAnimatedComponents).toBe(1); unmount(); expect(debugState.debugNumAnimatedComponents).toBe(0); }); - it('should not render when showSpinner is false', () => { + it('should not render when showSpinner is false', async () => { const settings = createMockSettings({ ui: { showSpinner: false } }); - const { lastFrame } = renderWithProviders(, { settings }); - expect(lastFrame()).toBe(''); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { settings }, + ); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 45ccc684b7..946a041841 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -245,13 +245,13 @@ const createMockConfig = (overrides = {}): Config => ...overrides, }) as unknown as Config; -const renderComposer = ( +const renderComposer = async ( uiState: UIState, settings = createMockSettings(), config = createMockConfig(), uiActions = createMockUIActions(), -) => - render( +) => { + const result = render( @@ -262,6 +262,9 @@ const renderComposer = ( , ); + await result.waitUntilReady(); + return result; +}; describe('Composer', () => { beforeEach(() => { @@ -274,20 +277,20 @@ describe('Composer', () => { }); describe('Footer Display Settings', () => { - it('renders Footer by default when hideFooter is false', () => { + it('renders Footer by default when hideFooter is false', async () => { const uiState = createMockUIState(); const settings = createMockSettings({ ui: { hideFooter: false } }); - const { lastFrame } = renderComposer(uiState, settings); + const { lastFrame } = await renderComposer(uiState, settings); expect(lastFrame()).toContain('Footer'); }); - it('does NOT render Footer when hideFooter is true', () => { + it('does NOT render Footer when hideFooter is true', async () => { const uiState = createMockUIState(); const settings = createMockSettings({ ui: { hideFooter: true } }); - const { lastFrame } = renderComposer(uiState, settings); + const { lastFrame } = await renderComposer(uiState, settings); // Check for content that only appears IN the Footer component itself expect(lastFrame()).not.toContain('[NORMAL]'); // Vim mode indicator @@ -331,7 +334,7 @@ describe('Composer', () => { setVimMode: vi.fn(), } as unknown as ReturnType); - const { lastFrame } = renderComposer(uiState, settings, config); + const { lastFrame } = await renderComposer(uiState, settings, config); expect(lastFrame()).toContain('Footer'); // Footer should be rendered with all the state passed through @@ -339,7 +342,7 @@ describe('Composer', () => { }); describe('Loading Indicator', () => { - it('renders LoadingIndicator with thought when streaming', () => { + it('renders LoadingIndicator with thought when streaming', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { @@ -350,13 +353,13 @@ describe('Composer', () => { elapsedTime: 1500, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('LoadingIndicator: Processing'); }); - it('renders generic thinking text in loading indicator when full inline thinking is enabled', () => { + it('renders generic thinking text in loading indicator when full inline thinking is enabled', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { @@ -368,27 +371,27 @@ describe('Composer', () => { ui: { inlineThinkingMode: 'full' }, }); - const { lastFrame } = renderComposer(uiState, settings); + const { lastFrame } = await renderComposer(uiState, settings); const output = lastFrame(); expect(output).toContain('LoadingIndicator: Thinking ...'); }); - it('hides shortcuts hint while loading', () => { + it('hides shortcuts hint while loading', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, elapsedTime: 1, cleanUiDetailsVisible: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('LoadingIndicator'); expect(output).not.toContain('ShortcutsHint'); }); - it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => { + it('renders LoadingIndicator without thought when accessibility disables loading phrases', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { subject: 'Hidden', description: 'Should not show' }, @@ -397,14 +400,14 @@ describe('Composer', () => { getAccessibility: vi.fn(() => ({ enableLoadingPhrases: false })), }); - const { lastFrame } = renderComposer(uiState, undefined, config); + const { lastFrame } = await renderComposer(uiState, undefined, config); const output = lastFrame(); expect(output).toContain('LoadingIndicator'); expect(output).not.toContain('Should not show'); }); - it('does not render LoadingIndicator when waiting for confirmation', () => { + it('does not render LoadingIndicator when waiting for confirmation', async () => { const uiState = createMockUIState({ streamingState: StreamingState.WaitingForConfirmation, thought: { @@ -413,13 +416,13 @@ describe('Composer', () => { }, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).not.toContain('LoadingIndicator'); }); - it('does not render LoadingIndicator when a tool confirmation is pending', () => { + it('does not render LoadingIndicator when a tool confirmation is pending', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, pendingHistoryItems: [ @@ -439,27 +442,27 @@ describe('Composer', () => { ], }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).not.toContain('LoadingIndicator'); expect(output).not.toContain('esc to cancel'); }); - it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => { + it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, embeddedShellFocused: true, isBackgroundShellVisible: true, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('LoadingIndicator'); }); - it('renders both LoadingIndicator and ApprovalModeIndicator when streaming in full UI mode', () => { + it('renders both LoadingIndicator and ApprovalModeIndicator when streaming in full UI mode', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { @@ -469,21 +472,21 @@ describe('Composer', () => { showApprovalModeIndicator: ApprovalMode.PLAN, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('LoadingIndicator: Thinking'); expect(output).toContain('ApprovalModeIndicator'); }); - it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', () => { + it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, embeddedShellFocused: true, isBackgroundShellVisible: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).not.toContain('LoadingIndicator'); @@ -491,7 +494,7 @@ describe('Composer', () => { }); describe('Message Queue Display', () => { - it('displays queued messages when present', () => { + it('displays queued messages when present', async () => { const uiState = createMockUIState({ messageQueue: [ 'First queued message', @@ -500,7 +503,7 @@ describe('Composer', () => { ], }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('First queued message'); @@ -508,12 +511,12 @@ describe('Composer', () => { expect(output).toContain('Third queued message'); }); - it('renders QueuedMessageDisplay with empty message queue', () => { + it('renders QueuedMessageDisplay with empty message queue', async () => { const uiState = createMockUIState({ messageQueue: [], }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); // The component should render but return null for empty queue // This test verifies that the component receives the correct prop @@ -523,14 +526,14 @@ describe('Composer', () => { }); describe('Context and Status Display', () => { - it('shows StatusDisplay and ApprovalModeIndicator in normal state', () => { + it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => { const uiState = createMockUIState({ ctrlCPressedOnce: false, ctrlDPressedOnce: false, showEscapePrompt: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('StatusDisplay'); @@ -538,12 +541,12 @@ describe('Composer', () => { expect(output).not.toContain('ToastDisplay'); }); - it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', () => { + it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => { const uiState = createMockUIState({ ctrlCPressedOnce: true, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('ToastDisplay'); @@ -551,7 +554,7 @@ describe('Composer', () => { expect(output).toContain('StatusDisplay'); }); - it('shows ToastDisplay for other toast types', () => { + it('shows ToastDisplay for other toast types', async () => { const uiState = createMockUIState({ transientMessage: { text: 'Warning', @@ -559,7 +562,7 @@ describe('Composer', () => { }, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('ToastDisplay'); @@ -568,12 +571,12 @@ describe('Composer', () => { }); describe('Input and Indicators', () => { - it('hides non-essential UI details in clean mode', () => { + it('hides non-essential UI details in clean mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('ShortcutsHint'); @@ -583,22 +586,22 @@ describe('Composer', () => { expect(output).not.toContain('ContextSummaryDisplay'); }); - it('renders InputPrompt when input is active', () => { + it('renders InputPrompt when input is active', async () => { const uiState = createMockUIState({ isInputActive: true, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('InputPrompt'); }); - it('does not render InputPrompt when input is inactive', () => { + it('does not render InputPrompt when input is inactive', async () => { const uiState = createMockUIState({ isInputActive: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('InputPrompt'); }); @@ -610,44 +613,44 @@ describe('Composer', () => { [ApprovalMode.YOLO], ])( 'shows ApprovalModeIndicator when approval mode is %s and shell mode is inactive', - (mode) => { + async (mode) => { const uiState = createMockUIState({ showApprovalModeIndicator: mode, shellModeActive: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/); }, ); - it('shows ShellModeIndicator when shell mode is active', () => { + it('shows ShellModeIndicator when shell mode is active', async () => { const uiState = createMockUIState({ shellModeActive: true, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/); }); - it('shows RawMarkdownIndicator when renderMarkdown is false', () => { + it('shows RawMarkdownIndicator when renderMarkdown is false', async () => { const uiState = createMockUIState({ renderMarkdown: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('raw markdown mode'); }); - it('does not show RawMarkdownIndicator when renderMarkdown is true', () => { + it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => { const uiState = createMockUIState({ renderMarkdown: true, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('raw markdown mode'); }); @@ -658,18 +661,18 @@ describe('Composer', () => { [ApprovalMode.AUTO_EDIT, 'auto edit'], ])( 'shows minimal mode badge "%s" when clean UI details are hidden', - (mode, label) => { + async (mode, label) => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, showApprovalModeIndicator: mode, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain(label); }, ); - it('hides minimal mode badge while loading in clean mode', () => { + it('hides minimal mode badge while loading in clean mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, streamingState: StreamingState.Responding, @@ -677,14 +680,14 @@ describe('Composer', () => { showApprovalModeIndicator: ApprovalMode.PLAN, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('LoadingIndicator'); expect(output).not.toContain('plan'); expect(output).not.toContain('ShortcutsHint'); }); - it('hides minimal mode badge while action-required state is active', () => { + it('hides minimal mode badge while action-required state is active', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, showApprovalModeIndicator: ApprovalMode.PLAN, @@ -695,26 +698,26 @@ describe('Composer', () => { ), }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).not.toContain('plan'); expect(output).not.toContain('ShortcutsHint'); }); - it('shows Esc rewind prompt in minimal mode without showing full UI', () => { + it('shows Esc rewind prompt in minimal mode without showing full UI', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, showEscapePrompt: true, history: [{ id: 1, type: 'user', text: 'msg' }], }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); const output = lastFrame(); expect(output).toContain('ToastDisplay'); expect(output).not.toContain('ContextSummaryDisplay'); }); - it('shows context usage bleed-through when over 60%', () => { + it('shows context usage bleed-through when over 60%', async () => { const model = 'gemini-2.5-pro'; const uiState = createMockUIState({ cleanUiDetailsVisible: false, @@ -734,13 +737,13 @@ describe('Composer', () => { }, }); - const { lastFrame } = renderComposer(uiState, settings); + const { lastFrame } = await renderComposer(uiState, settings); expect(lastFrame()).toContain('%'); }); }); describe('Error Details Display', () => { - it('shows DetailedMessagesDisplay when showErrorDetails is true', () => { + it('shows DetailedMessagesDisplay when showErrorDetails is true', async () => { const uiState = createMockUIState({ showErrorDetails: true, filteredConsoleMessages: [ @@ -752,18 +755,18 @@ describe('Composer', () => { ], }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('DetailedMessagesDisplay'); expect(lastFrame()).toContain('ShowMoreLines'); }); - it('does not show error details when showErrorDetails is false', () => { + it('does not show error details when showErrorDetails is false', async () => { const uiState = createMockUIState({ showErrorDetails: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('DetailedMessagesDisplay'); }); @@ -780,7 +783,7 @@ describe('Composer', () => { setVimMode: vi.fn(), }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain( "InputPrompt: Press 'Esc' for NORMAL mode.", @@ -797,7 +800,7 @@ describe('Composer', () => { setVimMode: vi.fn(), }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain( "InputPrompt: Press 'i' for INSERT mode.", @@ -806,7 +809,7 @@ describe('Composer', () => { }); describe('Shortcuts Hint', () => { - it('hides shortcuts hint when showShortcutsHint setting is false', () => { + it('hides shortcuts hint when showShortcutsHint setting is false', async () => { const uiState = createMockUIState(); const settings = createMockSettings({ ui: { @@ -814,12 +817,12 @@ describe('Composer', () => { }, }); - const { lastFrame } = renderComposer(uiState, settings); + const { lastFrame } = await renderComposer(uiState, settings); expect(lastFrame()).not.toContain('ShortcutsHint'); }); - it('hides shortcuts hint when a action is required (e.g. dialog is open)', () => { + it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => { const uiState = createMockUIState({ customDialog: ( @@ -829,55 +832,55 @@ describe('Composer', () => { ), }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHint'); }); - it('keeps shortcuts hint visible when no action is required', () => { + it('keeps shortcuts hint visible when no action is required', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHint'); }); - it('shows shortcuts hint when full UI details are visible', () => { + it('shows shortcuts hint when full UI details are visible', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: true, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHint'); }); - it('hides shortcuts hint while loading in minimal mode', () => { + it('hides shortcuts hint while loading in minimal mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, streamingState: StreamingState.Responding, elapsedTime: 1, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHint'); }); - it('shows shortcuts help in minimal mode when toggled on', () => { + it('shows shortcuts help in minimal mode when toggled on', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, shortcutsHelpVisible: true, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHelp'); }); - it('hides shortcuts hint when suggestions are visible above input in alternate buffer', () => { + it('hides shortcuts hint when suggestions are visible above input in alternate buffer', async () => { composerTestControls.isAlternateBuffer = true; composerTestControls.suggestionsVisible = true; @@ -886,13 +889,13 @@ describe('Composer', () => { showApprovalModeIndicator: ApprovalMode.PLAN, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHint'); expect(lastFrame()).not.toContain('plan'); }); - it('hides approval mode indicator when suggestions are visible above input in alternate buffer', () => { + it('hides approval mode indicator when suggestions are visible above input in alternate buffer', async () => { composerTestControls.isAlternateBuffer = true; composerTestControls.suggestionsVisible = true; @@ -901,12 +904,12 @@ describe('Composer', () => { showApprovalModeIndicator: ApprovalMode.YOLO, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ApprovalModeIndicator'); }); - it('keeps shortcuts hint when suggestions are visible below input in regular buffer', () => { + it('keeps shortcuts hint when suggestions are visible below input in regular buffer', async () => { composerTestControls.isAlternateBuffer = false; composerTestControls.suggestionsVisible = true; @@ -914,36 +917,36 @@ describe('Composer', () => { cleanUiDetailsVisible: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHint'); }); }); describe('Shortcuts Help', () => { - it('shows shortcuts help in passive state', () => { + it('shows shortcuts help in passive state', async () => { const uiState = createMockUIState({ shortcutsHelpVisible: true, streamingState: StreamingState.Idle, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toContain('ShortcutsHelp'); }); - it('hides shortcuts help while streaming', () => { + it('hides shortcuts help while streaming', async () => { const uiState = createMockUIState({ shortcutsHelpVisible: true, streamingState: StreamingState.Responding, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHelp'); }); - it('hides shortcuts help when action is required', () => { + it('hides shortcuts help when action is required', async () => { const uiState = createMockUIState({ shortcutsHelpVisible: true, customDialog: ( @@ -953,20 +956,20 @@ describe('Composer', () => { ), }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).not.toContain('ShortcutsHelp'); }); }); describe('Snapshots', () => { - it('matches snapshot in idle state', () => { + it('matches snapshot in idle state', async () => { const uiState = createMockUIState(); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatchSnapshot(); }); - it('matches snapshot while streaming', () => { + it('matches snapshot while streaming', async () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, thought: { @@ -974,33 +977,33 @@ describe('Composer', () => { description: 'Thinking about the meaning of life...', }, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatchSnapshot(); }); - it('matches snapshot in narrow view', () => { + it('matches snapshot in narrow view', async () => { const uiState = createMockUIState({ terminalWidth: 40, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatchSnapshot(); }); - it('matches snapshot in minimal UI mode', () => { + it('matches snapshot in minimal UI mode', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatchSnapshot(); }); - it('matches snapshot in minimal UI mode while loading', () => { + it('matches snapshot in minimal UI mode while loading', async () => { const uiState = createMockUIState({ cleanUiDetailsVisible: false, streamingState: StreamingState.Responding, elapsedTime: 1000, }); - const { lastFrame } = renderComposer(uiState); + const { lastFrame } = await renderComposer(uiState); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx index 9c7978400f..d942f8c55f 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx @@ -42,8 +42,9 @@ describe('ConfigInitDisplay', () => { vi.restoreAllMocks(); }); - it('renders initial state', () => { - const { lastFrame } = render(); + it('renders initial state', async () => { + const { lastFrame, waitUntilReady } = render(); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); diff --git a/packages/cli/src/ui/components/ConsentPrompt.test.tsx b/packages/cli/src/ui/components/ConsentPrompt.test.tsx index 324681f196..dd69c44dd5 100644 --- a/packages/cli/src/ui/components/ConsentPrompt.test.tsx +++ b/packages/cli/src/ui/components/ConsentPrompt.test.tsx @@ -31,15 +31,16 @@ describe('ConsentPrompt', () => { vi.clearAllMocks(); }); - it('renders a string prompt with MarkdownDisplay', () => { + it('renders a string prompt with MarkdownDisplay', async () => { const prompt = 'Are you sure?'; - const { unmount } = render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(MockedMarkdownDisplay).toHaveBeenCalledWith( { @@ -52,15 +53,16 @@ describe('ConsentPrompt', () => { unmount(); }); - it('renders a ReactNode prompt directly', () => { + it('renders a ReactNode prompt directly', async () => { const prompt = Are you sure?; - const { lastFrame, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(MockedMarkdownDisplay).not.toHaveBeenCalled(); expect(lastFrame()).toContain('Are you sure?'); @@ -69,18 +71,20 @@ describe('ConsentPrompt', () => { it('calls onConfirm with true when "Yes" is selected', async () => { const prompt = 'Are you sure?'; - const { unmount } = render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; await act(async () => { onSelect(true); }); + await waitUntilReady(); expect(onConfirm).toHaveBeenCalledWith(true); unmount(); @@ -88,32 +92,35 @@ describe('ConsentPrompt', () => { it('calls onConfirm with false when "No" is selected', async () => { const prompt = 'Are you sure?'; - const { unmount } = render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; await act(async () => { onSelect(false); }); + await waitUntilReady(); expect(onConfirm).toHaveBeenCalledWith(false); unmount(); }); - it('passes correct items to RadioButtonSelect', () => { + it('passes correct items to RadioButtonSelect', async () => { const prompt = 'Are you sure?'; - const { unmount } = render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(MockedRadioButtonSelect).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx index aacb90e1e3..cb8db1a895 100644 --- a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx @@ -9,19 +9,27 @@ import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; import { describe, it, expect } from 'vitest'; describe('ConsoleSummaryDisplay', () => { - it('renders nothing when errorCount is 0', () => { - const { lastFrame } = render(); - expect(lastFrame()).toBe(''); + it('renders nothing when errorCount is 0', async () => { + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); }); it.each([ [1, '1 error'], [5, '5 errors'], - ])('renders correct message for %i errors', (count, expectedText) => { - const { lastFrame } = render(); + ])('renders correct message for %i errors', async (count, expectedText) => { + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain(expectedText); expect(output).toContain('✖'); expect(output).toContain('(F12 for details)'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx index 415e867a87..f48cfb2a31 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -21,12 +21,14 @@ afterEach(() => { vi.useRealTimers(); }); -const renderWithWidth = ( +const renderWithWidth = async ( width: number, props: React.ComponentProps, ) => { useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); - return render(); + const result = render(); + await result.waitUntilReady(); + return result; }; describe('', () => { @@ -42,7 +44,7 @@ describe('', () => { skillCount: 1, }; - it('should render on a single line on a wide screen', () => { + it('should render on a single line on a wide screen', async () => { const props = { ...baseProps, geminiMdFileCount: 1, @@ -54,12 +56,12 @@ describe('', () => { }, }, }; - const { lastFrame, unmount } = renderWithWidth(120, props); + const { lastFrame, unmount } = await renderWithWidth(120, props); expect(lastFrame()).toMatchSnapshot(); unmount(); }); - it('should render on multiple lines on a narrow screen', () => { + it('should render on multiple lines on a narrow screen', async () => { const props = { ...baseProps, geminiMdFileCount: 1, @@ -71,12 +73,12 @@ describe('', () => { }, }, }; - const { lastFrame, unmount } = renderWithWidth(60, props); + const { lastFrame, unmount } = await renderWithWidth(60, props); expect(lastFrame()).toMatchSnapshot(); unmount(); }); - it('should switch layout at the 80-column breakpoint', () => { + it('should switch layout at the 80-column breakpoint', async () => { const props = { ...baseProps, geminiMdFileCount: 1, @@ -90,23 +92,19 @@ describe('', () => { }; // At 80 columns, should be on one line - const { lastFrame: wideFrame, unmount: unmountWide } = renderWithWidth( - 80, - props, - ); - expect(wideFrame()!.includes('\n')).toBe(false); + const { lastFrame: wideFrame, unmount: unmountWide } = + await renderWithWidth(80, props); + expect(wideFrame().trim().includes('\n')).toBe(false); unmountWide(); // At 79 columns, should be on multiple lines - const { lastFrame: narrowFrame, unmount: unmountNarrow } = renderWithWidth( - 79, - props, - ); - expect(narrowFrame()!.includes('\n')).toBe(true); - expect(narrowFrame()!.split('\n').length).toBe(4); + const { lastFrame: narrowFrame, unmount: unmountNarrow } = + await renderWithWidth(79, props); + expect(narrowFrame().trim().includes('\n')).toBe(true); + expect(narrowFrame().trim().split('\n').length).toBe(4); unmountNarrow(); }); - it('should not render empty parts', () => { + it('should not render empty parts', async () => { const props = { ...baseProps, geminiMdFileCount: 0, @@ -119,7 +117,7 @@ describe('', () => { }, }, }; - const { lastFrame, unmount } = renderWithWidth(60, props); + const { lastFrame, unmount } = await renderWithWidth(60, props); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index 4183090559..ae272d6145 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -27,40 +27,46 @@ vi.mock('../../config/settings.js', () => ({ })); describe('ContextUsageDisplay', () => { - it('renders correct percentage left', () => { - const { lastFrame } = render( + it('renders correct percentage left', async () => { + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('50% context left'); + unmount(); }); - it('renders short label when terminal width is small', () => { - const { lastFrame } = render( + it('renders short label when terminal width is small', async () => { + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('80%'); expect(output).not.toContain('context left'); + unmount(); }); - it('renders 0% when full', () => { - const { lastFrame } = render( + it('renders 0% when full', async () => { + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('0% context left'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx index 63ca84369f..de7cb3a888 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.test.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx @@ -18,20 +18,24 @@ describe('CopyModeWarning', () => { vi.clearAllMocks(); }); - it('renders nothing when copy mode is disabled', () => { + it('renders nothing when copy mode is disabled', async () => { mockUseUIState.mockReturnValue({ copyModeEnabled: false, } as unknown as UIState); - const { lastFrame } = render(); - expect(lastFrame()).toBe(''); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); }); - it('renders warning when copy mode is enabled', () => { + it('renders warning when copy mode is enabled', async () => { mockUseUIState.mockReturnValue({ copyModeEnabled: true, } as unknown as UIState); - const { lastFrame } = render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); expect(lastFrame()).toContain('In Copy Mode'); expect(lastFrame()).toContain('Press any key to exit'); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/DebugProfiler.test.tsx b/packages/cli/src/ui/components/DebugProfiler.test.tsx index 6ccb9fdea6..d4c0e28902 100644 --- a/packages/cli/src/ui/components/DebugProfiler.test.tsx +++ b/packages/cli/src/ui/components/DebugProfiler.test.tsx @@ -231,28 +231,24 @@ describe('DebugProfiler Component', () => { showDebugProfiler: false, constrainHeight: false, } as unknown as UIState); - - // Mock process.stdin and stdout - // We need to be careful not to break the test runner's own output - // So we might want to skip mocking them if they are not strictly needed for the simple render test - // or mock them safely. - // For now, let's assume the component uses them in useEffect. }); afterEach(() => { vi.restoreAllMocks(); }); - it('should return null when showDebugProfiler is false', () => { + it('should return null when showDebugProfiler is false', async () => { vi.mocked(useUIState).mockReturnValue({ showDebugProfiler: false, constrainHeight: false, } as unknown as UIState); - const { lastFrame } = render(); - expect(lastFrame()).toBe(''); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); }); - it('should render stats when showDebugProfiler is true', () => { + it('should render stats when showDebugProfiler is true', async () => { vi.mocked(useUIState).mockReturnValue({ showDebugProfiler: true, constrainHeight: false, @@ -261,12 +257,14 @@ describe('DebugProfiler Component', () => { profiler.totalIdleFrames = 5; profiler.totalFlickerFrames = 2; - const { lastFrame } = render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Renders: 10 (total)'); expect(output).toContain('5 (idle)'); expect(output).toContain('2 (flicker)'); + unmount(); }); it('should report an action when a CoreEvent is emitted', async () => { @@ -277,11 +275,13 @@ describe('DebugProfiler Component', () => { const reportActionSpy = vi.spyOn(profiler, 'reportAction'); - const { unmount } = render(); + const { waitUntilReady, unmount } = render(); + await waitUntilReady(); - act(() => { + await act(async () => { coreEvents.emitModelChanged('new-model'); }); + await waitUntilReady(); expect(reportActionSpy).toHaveBeenCalled(); unmount(); @@ -295,11 +295,13 @@ describe('DebugProfiler Component', () => { const reportActionSpy = vi.spyOn(profiler, 'reportAction'); - const { unmount } = render(); + const { waitUntilReady, unmount } = render(); + await waitUntilReady(); - act(() => { + await act(async () => { appEvents.emit(AppEvent.SelectionWarning); }); + await waitUntilReady(); expect(reportActionSpy).toHaveBeenCalled(); unmount(); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index e605d71095..108db073d5 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -28,8 +28,8 @@ vi.mock('./shared/ScrollableList.js', () => ({ })); describe('DetailedMessagesDisplay', () => { - it('renders nothing when messages are empty', () => { - const { lastFrame } = render( + it('renders nothing when messages are empty', async () => { + const { lastFrame, waitUntilReady, unmount } = render( { hasFocus={false} />, ); - expect(lastFrame()).toBe(''); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); }); - it('renders messages correctly', () => { + it('renders messages correctly', async () => { const messages: ConsoleMessageItem[] = [ { type: 'log', content: 'Log message', count: 1 }, { type: 'warn', content: 'Warning message', count: 1 }, @@ -48,7 +50,7 @@ describe('DetailedMessagesDisplay', () => { { type: 'debug', content: 'Debug message', count: 1 }, ]; - const { lastFrame } = render( + const { lastFrame, waitUntilReady, unmount } = render( { hasFocus={true} />, ); + await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); + unmount(); }); - it('renders message counts', () => { + it('renders message counts', async () => { const messages: ConsoleMessageItem[] = [ { type: 'log', content: 'Repeated message', count: 5 }, ]; - const { lastFrame } = render( + const { lastFrame, waitUntilReady, unmount } = render( { hasFocus={false} />, ); + await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index da10e97d50..2dbdd5019b 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -101,12 +101,14 @@ describe('DialogManager', () => { selectedAgentDefinition: undefined, }; - it('renders nothing by default', () => { - const { lastFrame } = renderWithProviders( + it('renders nothing by default', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: baseUiState as Partial as UIState }, ); - expect(lastFrame()).toBe(''); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); }); const testCases: Array<[Partial, string]> = [ @@ -190,8 +192,8 @@ describe('DialogManager', () => { it.each(testCases)( 'renders %s when state is %o', - (uiStateOverride, expectedComponent) => { - const { lastFrame } = renderWithProviders( + async (uiStateOverride, expectedComponent) => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -200,7 +202,9 @@ describe('DialogManager', () => { } as Partial as UIState, }, ); + await waitUntilReady(); expect(lastFrame()).toContain(expectedComponent); + unmount(); }, ); }); diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx index ac5a16580b..36832c1662 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -56,38 +56,41 @@ describe('EditorSettingsDialog', () => { const renderWithProvider = (ui: React.ReactNode) => render({ui}); - it('renders correctly', () => { - const { lastFrame } = renderWithProvider( + it('renders correctly', async () => { + const { lastFrame, waitUntilReady } = renderWithProvider( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('calls onSelect when an editor is selected', () => { + it('calls onSelect when an editor is selected', async () => { const onSelect = vi.fn(); - const { lastFrame } = renderWithProvider( + const { lastFrame, waitUntilReady } = renderWithProvider( , ); + await waitUntilReady(); expect(lastFrame()).toContain('VS Code'); }); it('switches focus between editor and scope sections on Tab', async () => { - const { lastFrame, stdin } = renderWithProvider( + const { lastFrame, stdin, waitUntilReady } = renderWithProvider( , ); + await waitUntilReady(); // Initial focus on editor expect(lastFrame()).toContain('> Select Editor'); @@ -97,6 +100,7 @@ describe('EditorSettingsDialog', () => { await act(async () => { stdin.write('\t'); }); + await waitUntilReady(); // Focus should be on scope await waitFor(() => { @@ -115,6 +119,7 @@ describe('EditorSettingsDialog', () => { await act(async () => { stdin.write('\t'); }); + await waitUntilReady(); // Focus should be back on editor await waitFor(() => { @@ -124,24 +129,26 @@ describe('EditorSettingsDialog', () => { it('calls onExit when Escape is pressed', async () => { const onExit = vi.fn(); - const { stdin } = renderWithProvider( + const { stdin, waitUntilReady } = renderWithProvider( , ); + await waitUntilReady(); await act(async () => { stdin.write('\u001B'); // Escape }); + await waitUntilReady(); await waitFor(() => { expect(onExit).toHaveBeenCalled(); }); }); - it('shows modified message when setting exists in other scope', () => { + it('shows modified message when setting exists in other scope', async () => { const settingsWithOtherScope = { forScope: (_scope: string) => ({ settings: { @@ -157,13 +164,14 @@ describe('EditorSettingsDialog', () => { }, } as unknown as LoadedSettings; - const { lastFrame } = renderWithProvider( + const { lastFrame, waitUntilReady } = renderWithProvider( , ); + await waitUntilReady(); const frame = lastFrame() || ''; if (!frame.includes('(Also modified')) { diff --git a/packages/cli/src/ui/components/ExitWarning.test.tsx b/packages/cli/src/ui/components/ExitWarning.test.tsx index df91560cf0..6d495a5e21 100644 --- a/packages/cli/src/ui/components/ExitWarning.test.tsx +++ b/packages/cli/src/ui/components/ExitWarning.test.tsx @@ -18,43 +18,51 @@ describe('ExitWarning', () => { vi.clearAllMocks(); }); - it('renders nothing by default', () => { + it('renders nothing by default', async () => { mockUseUIState.mockReturnValue({ dialogsVisible: false, ctrlCPressedOnce: false, ctrlDPressedOnce: false, } as unknown as UIState); - const { lastFrame } = render(); - expect(lastFrame()).toBe(''); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); }); - it('renders Ctrl+C warning when pressed once and dialogs visible', () => { + it('renders Ctrl+C warning when pressed once and dialogs visible', async () => { mockUseUIState.mockReturnValue({ dialogsVisible: true, ctrlCPressedOnce: true, ctrlDPressedOnce: false, } as unknown as UIState); - const { lastFrame } = render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); expect(lastFrame()).toContain('Press Ctrl+C again to exit'); + unmount(); }); - it('renders Ctrl+D warning when pressed once and dialogs visible', () => { + it('renders Ctrl+D warning when pressed once and dialogs visible', async () => { mockUseUIState.mockReturnValue({ dialogsVisible: true, ctrlCPressedOnce: false, ctrlDPressedOnce: true, } as unknown as UIState); - const { lastFrame } = render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); expect(lastFrame()).toContain('Press Ctrl+D again to exit'); + unmount(); }); - it('renders nothing if dialogs are not visible', () => { + it('renders nothing if dialogs are not visible', async () => { mockUseUIState.mockReturnValue({ dialogsVisible: false, ctrlCPressedOnce: true, ctrlDPressedOnce: true, } as unknown as UIState); - const { lastFrame } = render(); - expect(lastFrame()).toBe(''); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 0597a8167b..832edd1d8a 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -36,10 +36,11 @@ describe('FolderTrustDialog', () => { mockedCwd.mockReturnValue('/home/user/project'); }); - it('should render the dialog with title and description', () => { - const { lastFrame, unmount } = renderWithProviders( + it('should render the dialog with title and description', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Do you trust this folder?'); expect(lastFrame()).toContain( @@ -50,13 +51,18 @@ describe('FolderTrustDialog', () => { it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => { const onSelect = vi.fn(); - const { lastFrame, stdin, unmount } = renderWithProviders( + const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); - act(() => { + await act(async () => { stdin.write('\u001b[27u'); // Press kitty escape key }); + // Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act + await act(async () => { + await waitUntilReady(); + }); await waitFor(() => { expect(lastFrame()).toContain( @@ -72,10 +78,11 @@ describe('FolderTrustDialog', () => { unmount(); }); - it('should display restart message when isRestarting is true', () => { - const { lastFrame, unmount } = renderWithProviders( + it('should display restart message when isRestarting is true', async () => { + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Gemini CLI is restarting'); unmount(); @@ -84,9 +91,10 @@ describe('FolderTrustDialog', () => { it('should call relaunchApp when isRestarting is true', async () => { vi.useFakeTimers(); const relaunchApp = vi.spyOn(processUtils, 'relaunchApp'); - const { unmount } = renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); await vi.advanceTimersByTimeAsync(250); expect(relaunchApp).toHaveBeenCalled(); unmount(); @@ -96,9 +104,10 @@ describe('FolderTrustDialog', () => { it('should not call relaunchApp if unmounted before timeout', async () => { vi.useFakeTimers(); const relaunchApp = vi.spyOn(processUtils, 'relaunchApp'); - const { unmount } = renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); // Unmount immediately (before 250ms) unmount(); @@ -109,13 +118,15 @@ describe('FolderTrustDialog', () => { }); it('should not call process.exit when "r" is pressed and isRestarting is false', async () => { - const { stdin, unmount } = renderWithProviders( + const { stdin, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); - act(() => { + await act(async () => { stdin.write('r'); }); + await waitUntilReady(); await waitFor(() => { expect(mockedExit).not.toHaveBeenCalled(); @@ -124,29 +135,32 @@ describe('FolderTrustDialog', () => { }); describe('directory display', () => { - it('should correctly display the folder name for a nested directory', () => { + it('should correctly display the folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Trust folder (project)'); unmount(); }); - it('should correctly display the parent folder name for a nested directory', () => { + it('should correctly display the parent folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Trust parent folder (user)'); unmount(); }); - it('should correctly display an empty parent folder name for a directory directly under root', () => { + it('should correctly display an empty parent folder name for a directory directly under root', async () => { mockedCwd.mockReturnValue('/project'); - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Trust parent folder ()'); unmount(); }); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 635a3bfa83..143e8319a3 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -60,206 +60,284 @@ const mockSessionStats: SessionStatsState = { }; describe('