diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml index c5b3709c75..ee17a95121 100644 --- a/.github/workflows/evals-nightly.yml +++ b/.github/workflows/evals-nightly.yml @@ -61,6 +61,7 @@ jobs: GEMINI_MODEL: '${{ matrix.model }}' RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}" TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' + VITEST_RETRY: 0 run: | CMD="npm run test:all_evals" PATTERN="${TEST_NAME_PATTERN}" diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index 167801ca05..bc8f8b44ce 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -50,6 +50,7 @@ These commands are available within the interactive REPL. | `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | | `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. | | `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. | | `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | | `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | | `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | diff --git a/docs/cli/git-worktrees.md b/docs/cli/git-worktrees.md new file mode 100644 index 0000000000..5020b3fa9a --- /dev/null +++ b/docs/cli/git-worktrees.md @@ -0,0 +1,107 @@ +# Git Worktrees (experimental) + +When working on multiple tasks at once, you can use Git worktrees to give each +Gemini session its own copy of the codebase. Git worktrees create separate +working directories that each have their own files and branch while sharing the +same repository history. This prevents changes in one session from colliding +with another. + +Learn more about [session management](./session-management.md). + + +> [!NOTE] +> This is an experimental feature currently under active development. Your +> feedback is invaluable as we refine this feature. If you have ideas, +> suggestions, or encounter issues: +> +> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub. +> - Use the **/bug** command within Gemini CLI to file an issue. + +Learn more in the official Git worktree +[documentation](https://git-scm.com/docs/git-worktree). + +## How to enable Git worktrees + +Git worktrees are an experimental feature. You must enable them in your settings +using the `/settings` command or by manually editing your `settings.json` file. + +1. Use the `/settings` command. +2. Search for and set **Enable Git Worktrees** to `true`. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "experimental": { + "worktrees": true + } +} +``` + +## How to use Git worktrees + +Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini +CLI in it. + +- **Start with a specific name:** The value you pass becomes both the directory + name (within `.gemini/worktrees/`) and the branch name. + + ```bash + gemini --worktree feature-search + ``` + +- **Start with a random name:** If you omit the name, Gemini generates a random + one automatically (for example, `worktree-a1b2c3d4`). + + ```bash + gemini --worktree + ``` + + +> [!NOTE] +> Remember to initialize your development environment in each new +> worktree according to your project's setup. Depending on your stack, this +> might include running dependency installation (`npm install`, `yarn`), setting +> up virtual environments, or following your project's standard build process. + +## How to exit a Git worktree session + +When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the +worktree intact so your work is not lost. This includes your uncommitted changes +(modified files, staged changes, or untracked files) and any new commits you +have made. + +Gemini prioritizes a fast and safe exit: it **does not automatically delete** +your worktree or branch. You are responsible for cleaning up your worktrees +manually once you are finished with them. + +When you exit, Gemini displays instructions on how to resume your work or how to +manually remove the worktree if you no longer need it. + +## Resuming work in a Git worktree + +To resume a session in a worktree, navigate to the worktree directory and start +Gemini CLI with the `--resume` flag and the session ID: + +```bash +cd .gemini/worktrees/feature-search +gemini --resume +``` + +## Managing Git worktrees manually + +For more control over worktree location and branch configuration, or to clean up +a preserved worktree, you can use Git directly: + +- **Clean up a preserved Git worktree:** + ```bash + git worktree remove .gemini/worktrees/feature-search --force + git branch -D worktree-feature-search + ``` +- **Create a Git worktree manually:** + ```bash + git worktree add ../project-feature-search -b feature-search + cd ../project-feature-search && gemini + ``` + +[Open an issue]: https://github.com/google-gemini/gemini-cli/issues diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md index 8e60f61630..74bc4a4337 100644 --- a/docs/cli/session-management.md +++ b/docs/cli/session-management.md @@ -96,6 +96,12 @@ Compatibility aliases: - `/chat ...` works for the same commands. - `/resume checkpoints ...` also remains supported during migration. +## Parallel sessions with Git worktrees + +When working on multiple tasks at once, you can use +[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of +the codebase. This prevents changes in one session from colliding with another. + ## Managing sessions You can list and delete sessions to keep your history organized and manage disk diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 85373f1034..2a4b5963ce 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -101,6 +101,13 @@ they appear in the UI. | Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` | | Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` | +### Agents + +| UI Label | Setting | Description | Default | +| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------- | ------- | +| Confirm Sensitive Actions | `agents.browser.confirmSensitiveActions` | Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script). | `false` | +| Block File Uploads | `agents.browser.blockFileUploads` | Hard-block file upload requests from the browser agent. | `false` | + ### Context | UI Label | Setting | Description | Default | @@ -151,6 +158,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | +| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` | | Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Plan | `experimental.plan` | Enable Plan Mode. | `true` | diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index 2068759213..dd13d5eb82 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -306,6 +306,7 @@ Emitted at startup with the CLI configuration. - `extension_ids` (string) - `extensions_count` (int) - `auth_type` (string) +- `worktree_active` (boolean) - `github_workflow_name` (string, optional) - `github_repository_hash` (string, optional) - `github_event_name` (string, optional) @@ -903,6 +904,20 @@ Logs keychain availability checks. - `available` (boolean) +##### `gemini_cli.startup_stats` + +Logs detailed startup performance statistics. + +
+Attributes + +- `phases` (json array of startup phases) +- `os_platform` (string) +- `os_release` (string) +- `is_docker` (boolean) + +
+ ### Metrics @@ -919,6 +934,20 @@ Gemini CLI exports several custom metrics. Incremented once per CLI startup. +##### Onboarding + +Tracks onboarding flow from authentication to the user + +- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the + authentication flow begins. + +- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user +onboarding flow completes successfully. +
+Attributes (Success) + +- `user_tier` (string) + ##### Tools ##### `gemini_cli.tool.call.count` diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 708caeb08d..56c51d30df 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -23,7 +23,7 @@ Gemini CLI creates a copy of the extension during installation. You must run GitHub, you must have `git` installed on your machine. ```bash -gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] +gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] [--skip-settings] ``` - ``: The GitHub URL or local path of the extension. @@ -31,6 +31,7 @@ gemini extensions install [--ref ] [--auto-update] [--pre-release] - `--auto-update`: Enable automatic updates for this extension. - `--pre-release`: Enable installation of pre-release versions. - `--consent`: Acknowledge security risks and skip the confirmation prompt. +- `--skip-settings`: Skip the configuration on install process. ### Uninstall an extension diff --git a/docs/hooks/writing-hooks.md b/docs/hooks/writing-hooks.md index ca40d1976c..f4f156776f 100644 --- a/docs/hooks/writing-hooks.md +++ b/docs/hooks/writing-hooks.md @@ -470,5 +470,5 @@ console.error('Consolidating memories for session end...'); While project-level hooks are great for specific repositories, you can share your hooks across multiple projects by packaging them as a -[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md). -This provides version control, easy distribution, and centralized management. +[Gemini CLI extension](../extensions/index.md). This provides version control, +easy distribution, and centralized management. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index d3b08d565a..47b0d8124a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1210,6 +1210,17 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Disable user input on browser window during automation. - **Default:** `true` +- **`agents.browser.confirmSensitiveActions`** (boolean): + - **Description:** Require manual confirmation for sensitive browser actions + (e.g., fill_form, evaluate_script). + - **Default:** `false` + - **Requires restart:** Yes + +- **`agents.browser.blockFileUploads`** (boolean): + - **Description:** Hard-block file upload requests from the browser agent. + - **Default:** `false` + - **Requires restart:** Yes + #### `context` - **`context.fileName`** (string | string[]): @@ -1527,6 +1538,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`experimental.worktrees`** (boolean): + - **Description:** Enable automated Git worktree management for parallel work. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.extensionManagement`** (boolean): - **Description:** Enable extension management features. - **Default:** `true` diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index c0ce814793..456c8a9dc8 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -262,8 +262,8 @@ Here is a breakdown of the fields available in a TOML policy rule: # A unique name for the tool, or an array of names. toolName = "run_shell_command" -# (Optional) The name of a subagent. If provided, the rule only applies to tool calls -# made by this specific subagent. +# (Optional) The name of a subagent. If provided, the rule only applies to tool +# calls made by this specific subagent. subagent = "generalist" # (Optional) The name of an MCP server. Can be combined with toolName @@ -278,14 +278,17 @@ toolAnnotations = { readOnlyHint = true } argsPattern = '"command":"(git|npm)' # (Optional) A string or array of strings that a shell command must start with. -# This is syntactic sugar for `toolName = "run_shell_command"` and an `argsPattern`. +# This is syntactic sugar for `toolName = "run_shell_command"` and an +# `argsPattern`. commandPrefix = "git" # (Optional) A regex to match against the entire shell command. # This is also syntactic sugar for `toolName = "run_shell_command"`. -# Note: This pattern is tested against the JSON representation of the arguments (e.g., `{"command":""}`). -# Because it prepends `"command":"`, it effectively matches from the start of the command. -# Anchors like `^` or `$` apply to the full JSON string, so `^` should usually be avoided here. +# Note: This pattern is tested against the JSON representation of the arguments +# (e.g., `{"command":""}`). Because it prepends `"command":"`, +# it effectively matches from the start of the command. +# Anchors like `^` or `$` apply to the full JSON string, +# so `^` should usually be avoided here. # You cannot use commandPrefix and commandRegex in the same rule. commandRegex = "git (commit|push)" @@ -295,14 +298,16 @@ decision = "ask_user" # The priority of the rule, from 0 to 999. priority = 10 -# (Optional) A custom message to display when a tool call is denied by this rule. -# This message is returned to the model and user, useful for explaining *why* it was denied. +# (Optional) A custom message to display when a tool call is denied by this +# rule. This message is returned to the model and user, +# useful for explaining *why* it was denied. deny_message = "Deletion is permanent" # (Optional) An array of approval modes where this rule is active. modes = ["autoEdit"] -# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments. +# (Optional) A boolean to restrict the rule to interactive (true) or +# non-interactive (false) environments. # If omitted, the rule applies to both. interactive = true ``` diff --git a/docs/sidebar.json b/docs/sidebar.json index 6cac5ec9fd..7198a0336b 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -99,6 +99,11 @@ { "label": "Agent Skills", "slug": "docs/cli/skills" }, { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Headless mode", "slug": "docs/cli/headless" }, + { + "label": "Git worktrees", + "badge": "🔬", + "slug": "docs/cli/git-worktrees" + }, { "label": "Hooks", "collapsed": true, diff --git a/eslint.config.js b/eslint.config.js index 38dec43857..e827f9b236 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -35,6 +35,12 @@ const commonRestrictedSyntaxRules = [ message: 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', }, + { + selector: + 'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]', + message: + 'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.', + }, ]; export default tseslint.config( @@ -133,16 +139,7 @@ export default tseslint.config( 'no-cond-assign': 'error', 'no-debugger': 'error', 'no-duplicate-case': 'error', - 'no-restricted-syntax': [ - 'error', - ...commonRestrictedSyntaxRules, - { - selector: - 'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]', - message: - 'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.', - }, - ], + 'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules], 'no-unsafe-finally': 'error', 'no-unused-expressions': 'off', // Disable base rule '@typescript-eslint/no-unused-expressions': [ @@ -161,6 +158,7 @@ export default tseslint.config( '@typescript-eslint/await-thenable': ['error'], '@typescript-eslint/no-floating-promises': ['error'], '@typescript-eslint/no-unnecessary-type-assertion': ['error'], + '@typescript-eslint/no-misused-spread': ['error'], 'no-restricted-imports': [ 'error', { diff --git a/evals/app-test-helper.ts b/evals/app-test-helper.ts index 89f1582bdc..2bcff41924 100644 --- a/evals/app-test-helper.ts +++ b/evals/app-test-helper.ts @@ -15,9 +15,26 @@ import fs from 'node:fs'; import path from 'node:path'; import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core'; +/** + * Config overrides for evals, with tool-restriction fields explicitly + * forbidden. Evals must test against the full, default tool set to ensure + * realistic behavior. + */ +interface EvalConfigOverrides { + /** Restricting tools via excludeTools in evals is forbidden. */ + excludeTools?: never; + /** Restricting tools via coreTools in evals is forbidden. */ + coreTools?: never; + /** Restricting tools via allowedTools in evals is forbidden. */ + allowedTools?: never; + /** Restricting tools via mainAgentTools in evals is forbidden. */ + mainAgentTools?: never; + [key: string]: unknown; +} + export interface AppEvalCase { name: string; - configOverrides?: any; + configOverrides?: EvalConfigOverrides; prompt: string; timeout?: number; files?: Record; diff --git a/evals/cli_help_delegation.eval.ts b/evals/cli_help_delegation.eval.ts new file mode 100644 index 0000000000..8be3bf1c51 --- /dev/null +++ b/evals/cli_help_delegation.eval.ts @@ -0,0 +1,25 @@ +import { describe, expect } from 'vitest'; +import { evalTest } from './test-helper.js'; + +describe('CliHelpAgent Delegation', () => { + evalTest('USUALLY_PASSES', { + name: 'should delegate to cli_help agent for subagent creation questions', + params: { + settings: { + experimental: { + enableAgents: true, + }, + }, + }, + prompt: 'Help me create a subagent in this project', + timeout: 60000, + assert: async (rig, _result) => { + const toolLogs = rig.readToolLogs(); + const toolCallIndex = toolLogs.findIndex( + (log) => log.toolRequest.name === 'cli_help', + ); + expect(toolCallIndex).toBeGreaterThan(-1); + expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns + }, + }); +}); diff --git a/evals/generalist_delegation.eval.ts b/evals/generalist_delegation.eval.ts index 7e6358ae1f..81252880eb 100644 --- a/evals/generalist_delegation.eval.ts +++ b/evals/generalist_delegation.eval.ts @@ -21,7 +21,6 @@ describe('generalist_delegation', () => { experimental: { enableAgents: true, }, - excludeTools: ['run_shell_command'], }, files: { 'file1.ts': 'console.log("no semi")', @@ -65,7 +64,6 @@ describe('generalist_delegation', () => { experimental: { enableAgents: true, }, - excludeTools: ['run_shell_command'], }, files: { 'src/a.ts': 'export const a = 1;', @@ -106,7 +104,6 @@ describe('generalist_delegation', () => { experimental: { enableAgents: true, }, - excludeTools: ['run_shell_command'], }, files: { 'README.md': 'This is a proyect.', @@ -141,7 +138,6 @@ describe('generalist_delegation', () => { experimental: { enableAgents: true, }, - excludeTools: ['run_shell_command'], }, files: { 'src/VERSION': '1.2.3', diff --git a/evals/model_steering.eval.ts b/evals/model_steering.eval.ts index 87109c1225..2cb87edcc2 100644 --- a/evals/model_steering.eval.ts +++ b/evals/model_steering.eval.ts @@ -12,10 +12,9 @@ import { appEvalTest } from './app-test-helper.js'; import { PolicyDecision } from '@google/gemini-cli-core'; describe('Model Steering Behavioral Evals', () => { - appEvalTest('ALWAYS_PASSES', { + appEvalTest('USUALLY_PASSES', { name: 'Corrective Hint: Model switches task based on hint during tool turn', configOverrides: { - excludeTools: ['run_shell_command', 'ls', 'google_web_search'], modelSteering: true, }, files: { @@ -52,10 +51,9 @@ describe('Model Steering Behavioral Evals', () => { }, }); - appEvalTest('ALWAYS_PASSES', { + appEvalTest('USUALLY_PASSES', { name: 'Suggestive Hint: Model incorporates user guidance mid-stream', configOverrides: { - excludeTools: ['run_shell_command', 'ls', 'google_web_search'], modelSteering: true, }, files: {}, diff --git a/evals/save_memory.eval.ts b/evals/save_memory.eval.ts index 901cbf3c17..8be7b39e35 100644 --- a/evals/save_memory.eval.ts +++ b/evals/save_memory.eval.ts @@ -16,9 +16,7 @@ describe('save_memory', () => { const rememberingFavoriteColor = "Agent remembers user's favorite color"; evalTest('ALWAYS_PASSES', { name: rememberingFavoriteColor, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `remember that my favorite color is blue. what is my favorite color? tell me that and surround it with $ symbol`, @@ -38,9 +36,7 @@ describe('save_memory', () => { const rememberingCommandRestrictions = 'Agent remembers command restrictions'; evalTest('USUALLY_PASSES', { name: rememberingCommandRestrictions, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `I don't want you to ever run npm commands.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); @@ -59,9 +55,7 @@ describe('save_memory', () => { const rememberingWorkflow = 'Agent remembers workflow preferences'; evalTest('USUALLY_PASSES', { name: rememberingWorkflow, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `I want you to always lint after building.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); @@ -81,9 +75,7 @@ describe('save_memory', () => { 'Agent ignores temporary conversation details'; evalTest('ALWAYS_PASSES', { name: ignoringTemporaryInformation, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `I'm going to get a coffee.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); @@ -106,9 +98,7 @@ describe('save_memory', () => { const rememberingPetName = "Agent remembers user's pet's name"; evalTest('ALWAYS_PASSES', { name: rememberingPetName, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `Please remember that my dog's name is Buddy.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); @@ -127,9 +117,7 @@ describe('save_memory', () => { const rememberingCommandAlias = 'Agent remembers custom command aliases'; evalTest('ALWAYS_PASSES', { name: rememberingCommandAlias, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `When I say 'start server', you should run 'npm run dev'.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); @@ -149,18 +137,6 @@ describe('save_memory', () => { "Agent ignores workspace's database schema location"; evalTest('USUALLY_PASSES', { name: ignoringDbSchemaLocation, - params: { - settings: { - tools: { - core: [ - 'save_memory', - 'list_directory', - 'read_file', - 'run_shell_command', - ], - }, - }, - }, prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); @@ -180,9 +156,7 @@ describe('save_memory', () => { "Agent remembers user's coding style preference"; evalTest('ALWAYS_PASSES', { name: rememberingCodingStyle, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `I prefer to use tabs instead of spaces for indentation.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); @@ -202,18 +176,6 @@ describe('save_memory', () => { 'Agent ignores workspace build artifact location'; evalTest('USUALLY_PASSES', { name: ignoringBuildArtifactLocation, - params: { - settings: { - tools: { - core: [ - 'save_memory', - 'list_directory', - 'read_file', - 'run_shell_command', - ], - }, - }, - }, prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); @@ -232,18 +194,6 @@ describe('save_memory', () => { const ignoringMainEntryPoint = "Agent ignores workspace's main entry point"; evalTest('USUALLY_PASSES', { name: ignoringMainEntryPoint, - params: { - settings: { - tools: { - core: [ - 'save_memory', - 'list_directory', - 'read_file', - 'run_shell_command', - ], - }, - }, - }, prompt: `The main entry point for this workspace is \`src/index.js\`.`, assert: async (rig, result) => { await rig.waitForTelemetryReady(); @@ -262,9 +212,7 @@ describe('save_memory', () => { const rememberingBirthday = "Agent remembers user's birthday"; evalTest('ALWAYS_PASSES', { name: rememberingBirthday, - params: { - settings: { tools: { core: ['save_memory'] } }, - }, + prompt: `My birthday is on June 15th.`, assert: async (rig, result) => { const wasToolCalled = await rig.waitForToolCall('save_memory'); diff --git a/evals/test-helper.ts b/evals/test-helper.ts index 786ec0e418..66143ddfb6 100644 --- a/evals/test-helper.ts +++ b/evals/test-helper.ts @@ -197,9 +197,25 @@ export function symlinkNodeModules(testDir: string) { } } +/** + * Settings that are forbidden in evals. Evals should never restrict which + * tools are available — they must test against the full, default tool set + * to ensure realistic behavior. + */ +interface ForbiddenToolSettings { + tools?: { + /** Restricting core tools in evals is forbidden. */ + core?: never; + [key: string]: unknown; + }; +} + export interface EvalCase { name: string; - params?: Record; + params?: { + settings?: ForbiddenToolSettings & Record; + [key: string]: unknown; + }; prompt: string; timeout?: number; files?: Record; diff --git a/evals/vitest.config.ts b/evals/vitest.config.ts index 50733a999c..3231f31a10 100644 --- a/evals/vitest.config.ts +++ b/evals/vitest.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ }, test: { testTimeout: 300000, // 5 minutes + // Retry in CI but not nightly to avoid blocking on API error. + retry: process.env['VITEST_RETRY'] + ? parseInt(process.env['VITEST_RETRY'], 10) + : 3, reporters: ['default', 'json'], outputFile: { json: 'evals/logs/report.json', diff --git a/integration-tests/browser-agent.cleanup.responses b/integration-tests/browser-agent.cleanup.responses index 988f2fa456..9cf7a7b356 100644 --- a/integration-tests/browser-agent.cleanup.responses +++ b/integration-tests/browser-agent.cleanup.responses @@ -1,2 +1,4 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have opened the page and the title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The task is complete. The page title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":20,"totalTokenCount":320}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":400,"candidatesTokenCount":5,"totalTokenCount":405}}]} diff --git a/integration-tests/browser-policy.test.ts b/integration-tests/browser-policy.test.ts index 1bfdc27415..f533cb3f5e 100644 --- a/integration-tests/browser-policy.test.ts +++ b/integration-tests/browser-policy.test.ts @@ -175,4 +175,36 @@ priority = 200 expect(output).toContain('browser_agent'); expect(output).toContain('completed successfully'); }); + + it('should show the visible warning when browser agent starts in existing session mode', async () => { + rig.setup('browser-session-warning', { + fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'), + settings: { + general: { + enableAutoUpdateNotification: false, + }, + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + sessionMode: 'existing', + headless: true, + }, + }, + }, + }); + + const stdout = await rig.runCommand(['Open https://example.com'], { + env: { + GEMINI_API_KEY: 'fake-key', + GEMINI_TELEMETRY_DISABLED: 'true', + DEV: 'true', + }, + }); + + expect(stdout).toContain('saved logins will be visible'); + }); }); diff --git a/integration-tests/hooks-system.test.ts b/integration-tests/hooks-system.test.ts index 479851957b..4fe63a3ab6 100644 --- a/integration-tests/hooks-system.test.ts +++ b/integration-tests/hooks-system.test.ts @@ -7,9 +7,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { TestRig, poll, normalizePath } from './test-helper.js'; import { join } from 'node:path'; -import { writeFileSync } from 'node:fs'; +import { writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import os from 'node:os'; -describe('Hooks System Integration', () => { +describe('Hooks System Integration', { timeout: 120000 }, () => { let rig: TestRig; beforeEach(() => { @@ -2016,6 +2017,10 @@ console.log(JSON.stringify({ // 3. Final setup with full settings rig.setup('Hook Disabling Multiple Ops', { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.disabled-via-command.responses', + ), settings: { hooksConfig: { enabled: true, @@ -2230,7 +2235,7 @@ console.log(JSON.stringify({ // The hook should have stopped execution message (returned from tool) expect(result).toContain( - 'Agent execution stopped: Emergency Stop triggered by hook', + 'Agent execution stopped by hook: Emergency Stop triggered by hook', ); // Tool should NOT be called successfully (it was blocked/stopped) @@ -2242,4 +2247,210 @@ console.log(JSON.stringify({ expect(writeFileCalls).toHaveLength(0); }); }); + + describe('Hooks "ask" Decision Integration', () => { + it( + 'should force confirmation prompt when hook returns "ask" decision even in YOLO mode', + { timeout: 60000 }, + async () => { + const testName = + 'should force confirmation prompt when hook returns "ask" decision even in YOLO mode'; + + // 1. Setup hook script that returns 'ask' decision + const hookOutput = { + decision: 'ask', + systemMessage: 'Confirmation forced by security hook', + hookSpecificOutput: { + hookEventName: 'BeforeTool', + }, + }; + + const hookScript = `console.log(JSON.stringify(${JSON.stringify( + hookOutput, + )}));`; + + // Create script path predictably + const scriptPath = join(os.tmpdir(), 'gemini-cli-tests-ask-hook.js'); + writeFileSync(scriptPath, hookScript); + + // 2. Setup rig with YOLO mode enabled but with the 'ask' hook + rig.setup(testName, { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.allow-tool.responses', + ), + settings: { + debugMode: true, + tools: { + approval: 'yolo', + }, + general: { + enableAutoUpdateNotification: false, + }, + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeTool: [ + { + matcher: 'write_file', + hooks: [ + { + type: 'command', + command: `node "${scriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }); + + // Bypass terminal setup prompt and other startup banners + const stateDir = join(rig.homeDir!, '.gemini'); + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + writeFileSync( + join(stateDir, 'state.json'), + JSON.stringify({ + terminalSetupPromptShown: true, + hasSeenScreenReaderNudge: true, + tipsShown: 100, + }), + ); + + // 3. Run interactive and verify prompt appears despite YOLO mode + const run = await rig.runInteractive(); + + // Wait for prompt to appear + await run.expectText('Type your message', 30000); + + // Send prompt that will trigger write_file + await run.type('Create a file called ask-test.txt with content "test"'); + await run.type('\r'); + + // Wait for the FORCED confirmation prompt to appear + // It should contain the system message from the hook + await run.expectText('Confirmation forced by security hook', 30000); + await run.expectText('Allow', 5000); + + // 4. Approve the permission + await run.type('y'); + await run.type('\r'); + + // Wait for command to execute + await run.expectText('approved.txt', 30000); + + // Should find the tool call + const foundWriteFile = await rig.waitForToolCall('write_file'); + expect(foundWriteFile).toBeTruthy(); + + // File should be created + const fileContent = rig.readFile('approved.txt'); + expect(fileContent).toBe('Approved content'); + }, + ); + + it( + 'should allow cancelling when hook forces "ask" decision', + { timeout: 60000 }, + async () => { + const testName = + 'should allow cancelling when hook forces "ask" decision'; + const hookOutput = { + decision: 'ask', + systemMessage: 'Confirmation forced for cancellation test', + hookSpecificOutput: { + hookEventName: 'BeforeTool', + }, + }; + + const hookScript = `console.log(JSON.stringify(${JSON.stringify( + hookOutput, + )}));`; + + const scriptPath = join( + os.tmpdir(), + 'gemini-cli-tests-ask-cancel-hook.js', + ); + writeFileSync(scriptPath, hookScript); + + rig.setup(testName, { + fakeResponsesPath: join( + import.meta.dirname, + 'hooks-system.allow-tool.responses', + ), + settings: { + debugMode: true, + tools: { + approval: 'yolo', + }, + general: { + enableAutoUpdateNotification: false, + }, + hooksConfig: { + enabled: true, + }, + hooks: { + BeforeTool: [ + { + matcher: 'write_file', + hooks: [ + { + type: 'command', + command: `node "${scriptPath}"`, + timeout: 5000, + }, + ], + }, + ], + }, + }, + }); + + // Bypass terminal setup prompt and other startup banners + const stateDir = join(rig.homeDir!, '.gemini'); + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + writeFileSync( + join(stateDir, 'state.json'), + JSON.stringify({ + terminalSetupPromptShown: true, + hasSeenScreenReaderNudge: true, + tipsShown: 100, + }), + ); + + const run = await rig.runInteractive(); + + // Wait for prompt to appear + await run.expectText('Type your message', 30000); + + await run.type( + 'Create a file called cancel-test.txt with content "test"', + ); + await run.type('\r'); + + await run.expectText( + 'Confirmation forced for cancellation test', + 30000, + ); + + // 4. Deny the permission using option 4 + await run.type('4'); + await run.type('\r'); + + // Wait for cancellation message + await run.expectText('Cancelled', 15000); + + // Tool should NOT be called successfully + const toolLogs = rig.readToolLogs(); + const writeFileCalls = toolLogs.filter( + (t) => + t.toolRequest.name === 'write_file' && + t.toolRequest.success === true, + ); + expect(writeFileCalls).toHaveLength(0); + }, + ); + }); }); diff --git a/package.json b/package.json index 72676cf90b..414f9341ac 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests", "test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests", "test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests", - "lint": "eslint . --cache", + "lint": "eslint . --cache --max-warnings 0", "lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format", "lint:ci": "npm run lint:all", "lint:all": "node scripts/lint.js", diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 417e750651..8b3f8c5807 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -12,48 +12,46 @@ import { beforeEach, afterEach, type MockInstance, - type Mock, } from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; import * as core from '@google/gemini-cli-core'; -import { - ExtensionManager, - type inferInstallMetadata, -} from '../../config/extension-manager.js'; -import type { - promptForConsentNonInteractive, - requestConsentNonInteractive, -} from '../../config/extensions/consent.js'; -import type { - isWorkspaceTrusted, - loadTrustedFolders, -} from '../../config/trustedFolders.js'; -import type * as fs from 'node:fs/promises'; import type { Stats } from 'node:fs'; import * as path from 'node:path'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; -const mockInstallOrUpdateExtension: Mock< - typeof ExtensionManager.prototype.installOrUpdateExtension -> = vi.hoisted(() => vi.fn()); -const mockRequestConsentNonInteractive: Mock< - typeof requestConsentNonInteractive -> = vi.hoisted(() => vi.fn()); -const mockPromptForConsentNonInteractive: Mock< - typeof promptForConsentNonInteractive -> = vi.hoisted(() => vi.fn()); -const mockStat: Mock = vi.hoisted(() => vi.fn()); -const mockInferInstallMetadata: Mock = vi.hoisted( - () => vi.fn(), -); -const mockIsWorkspaceTrusted: Mock = vi.hoisted(() => - vi.fn(), -); -const mockLoadTrustedFolders: Mock = vi.hoisted(() => - vi.fn(), -); -const mockDiscover: Mock = - vi.hoisted(() => vi.fn()); +const { + mockInstallOrUpdateExtension, + mockLoadExtensions, + mockExtensionManager, + mockRequestConsentNonInteractive, + mockPromptForConsentNonInteractive, + mockStat, + mockInferInstallMetadata, + mockIsWorkspaceTrusted, + mockLoadTrustedFolders, + mockDiscover, +} = vi.hoisted(() => { + const mockLoadExtensions = vi.fn(); + const mockInstallOrUpdateExtension = vi.fn(); + const mockExtensionManager = vi.fn().mockImplementation(() => ({ + loadExtensions: mockLoadExtensions, + installOrUpdateExtension: mockInstallOrUpdateExtension, + })); + + return { + mockLoadExtensions, + mockInstallOrUpdateExtension, + mockExtensionManager, + mockRequestConsentNonInteractive: vi.fn(), + mockPromptForConsentNonInteractive: vi.fn(), + mockStat: vi.fn(), + mockInferInstallMetadata: vi.fn(), + mockIsWorkspaceTrusted: vi.fn(), + mockLoadTrustedFolders: vi.fn(), + mockDiscover: vi.fn(), + }; +}); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: mockRequestConsentNonInteractive, @@ -84,6 +82,7 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => ({ ...(await importOriginal< typeof import('../../config/extension-manager.js') >()), + ExtensionManager: mockExtensionManager, inferInstallMetadata: mockInferInstallMetadata, })); @@ -117,19 +116,18 @@ describe('handleInstall', () => { let processSpy: MockInstance; beforeEach(() => { - debugLogSpy = vi.spyOn(core.debugLogger, 'log'); - debugErrorSpy = vi.spyOn(core.debugLogger, 'error'); + debugLogSpy = vi + .spyOn(core.debugLogger, 'log') + .mockImplementation(() => {}); + debugErrorSpy = vi + .spyOn(core.debugLogger, 'error') + .mockImplementation(() => {}); processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); - vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue( - [], - ); - vi.spyOn( - ExtensionManager.prototype, - 'installOrUpdateExtension', - ).mockImplementation(mockInstallOrUpdateExtension); + mockLoadExtensions.mockResolvedValue([]); + mockInstallOrUpdateExtension.mockReset(); mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' }); mockDiscover.mockResolvedValue({ @@ -163,12 +161,7 @@ describe('handleInstall', () => { }); afterEach(() => { - mockInstallOrUpdateExtension.mockClear(); - mockRequestConsentNonInteractive.mockClear(); - mockStat.mockClear(); - mockInferInstallMetadata.mockClear(); vi.clearAllMocks(); - vi.restoreAllMocks(); }); function createMockExtension( @@ -288,6 +281,39 @@ describe('handleInstall', () => { expect(processSpy).toHaveBeenCalledWith(1); }); + it('should pass promptForSetting when skipSettings is not provided', async () => { + mockInstallOrUpdateExtension.mockResolvedValue({ + name: 'test-extension', + } as unknown as core.GeminiCLIExtension); + + await handleInstall({ + source: 'http://google.com', + }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + requestSetting: promptForSetting, + }), + ); + }); + + it('should pass null for requestSetting when skipSettings is true', async () => { + mockInstallOrUpdateExtension.mockResolvedValue({ + name: 'test-extension', + } as unknown as core.GeminiCLIExtension); + + await handleInstall({ + source: 'http://google.com', + skipSettings: true, + }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + requestSetting: null, + }), + ); + }); + it('should proceed if local path is already trusted', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 542d1240be..cf135a9366 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -37,6 +37,7 @@ interface InstallArgs { autoUpdate?: boolean; allowPreRelease?: boolean; consent?: boolean; + skipSettings?: boolean; } export async function handleInstall(args: InstallArgs) { @@ -153,7 +154,7 @@ export async function handleInstall(args: InstallArgs) { const extensionManager = new ExtensionManager({ workspaceDir, requestConsent, - requestSetting: promptForSetting, + requestSetting: args.skipSettings ? null : promptForSetting, settings, }); await extensionManager.loadExtensions(); @@ -196,6 +197,11 @@ export const installCommand: CommandModule = { type: 'boolean', default: false, }) + .option('skip-settings', { + describe: 'Skip the configuration on install process.', + type: 'boolean', + default: false, + }) .check((argv) => { if (!argv.source) { throw new Error('The source argument must be provided.'); @@ -214,6 +220,8 @@ export const installCommand: CommandModule = { allowPreRelease: argv['pre-release'] as boolean | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + skipSettings: argv['skip-settings'] as boolean | undefined, }); await exitCli(); }, diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index a1df1a8027..8154e3b7bf 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -54,6 +54,7 @@ export async function getMcpServersFromConfig( return; } mcpServers[key] = { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...server, extension, }; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c046f0c0e7..2325711ad0 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -226,6 +226,51 @@ afterEach(() => { }); describe('parseArguments', () => { + describe('worktree', () => { + it('should parse --worktree flag when provided with a name', async () => { + process.argv = ['node', 'script.js', '--worktree', 'my-feature']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = true; + const argv = await parseArguments(settings); + expect(argv.worktree).toBe('my-feature'); + }); + + it('should generate a random name when --worktree is provided without a name', async () => { + process.argv = ['node', 'script.js', '--worktree']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = true; + const argv = await parseArguments(settings); + expect(argv.worktree).toBeDefined(); + expect(argv.worktree).not.toBe(''); + expect(typeof argv.worktree).toBe('string'); + }); + + it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => { + process.argv = ['node', 'script.js', '--worktree', 'feature']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = false; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments(settings)).rejects.toThrow( + 'process.exit called', + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.', + ), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + }); + it.each([ { description: 'long flags', @@ -1671,6 +1716,7 @@ describe('loadCliConfig with admin.mcp.config', () => { const serverA = config.getMcpServers()?.['serverA']; expect(serverA).toEqual({ + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...localMcpServers['serverA'], type: 'sse', url: 'https://admin-server-a.com/sse', @@ -1721,6 +1767,7 @@ describe('loadCliConfig with admin.mcp.config', () => { }; const localMcpServersWithTools: Record = { serverA: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...localMcpServers['serverA'], includeTools: ['local_tool'], timeout: 1234, @@ -1763,6 +1810,7 @@ describe('loadCliConfig with admin.mcp.config', () => { }; const localMcpServersWithTools: Record = { serverA: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...localMcpServers['serverA'], includeTools: ['local_tool'], }, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a1595fa5cd..ea29bad02c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import yargs from 'yargs/yargs'; +import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import * as path from 'node:path'; +import { execa } from 'execa'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; import { skillsCommand } from '../commands/skills.js'; @@ -38,6 +39,9 @@ import { applyAdminAllowlist, applyRequiredServers, getAdminBlockedMcpServersMessage, + getProjectRootForWorktree, + isGeminiWorktree, + type WorktreeSettings, type HookDefinition, type HookEventName, type OutputFormat, @@ -48,6 +52,8 @@ import { type MergedSettings, saveModelChange, loadSettings, + isWorktreeEnabled, + type LoadedSettings, } from './settings.js'; import { loadSandboxConfig } from './sandboxConfig.js'; @@ -74,6 +80,7 @@ export interface CliArgs { debug: boolean | undefined; prompt: string | undefined; promptInteractive: string | undefined; + worktree?: string; yolo: boolean | undefined; approvalMode: string | undefined; @@ -116,6 +123,36 @@ const coerceCommaSeparated = (values: string[]): string[] => { ); }; +/** + * Pre-parses the command line arguments to find the worktree flag. + * Used for early setup before full argument parsing with settings. + */ +export function getWorktreeArg(argv: string[]): string | undefined { + const result = yargs(hideBin(argv)) + .help(false) + .version(false) + .option('worktree', { alias: 'w', type: 'string' }) + .strict(false) + .exitProcess(false) + .parseSync(); + + if (result.worktree === undefined) return undefined; + return typeof result.worktree === 'string' ? result.worktree.trim() : ''; +} + +/** + * Checks if a worktree is requested via CLI and enabled in settings. + * Returns the requested name (can be empty string for auto-generated) or undefined. + */ +export function getRequestedWorktreeName( + settings: LoadedSettings, +): string | undefined { + if (!isWorktreeEnabled(settings)) { + return undefined; + } + return getWorktreeArg(process.argv); +} + export async function parseArguments( settings: MergedSettings, ): Promise { @@ -159,6 +196,20 @@ export async function parseArguments( description: 'Execute the provided prompt and continue in interactive mode', }) + .option('worktree', { + alias: 'w', + type: 'string', + skipValidation: true, + description: + 'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.', + coerce: (value: unknown): string => { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (trimmed === '') { + return Math.random().toString(36).substring(2, 10); + } + return trimmed; + }, + }) .option('sandbox', { alias: 's', type: 'boolean', @@ -342,6 +393,9 @@ export async function parseArguments( ) { return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`; } + if (argv['worktree'] && !settings.experimental?.worktrees) { + return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.'; + } return true; }); @@ -427,6 +481,7 @@ export interface LoadCliConfigOptions { projectHooks?: { [K in HookEventName]?: HookDefinition[] } & { disabled?: string[]; }; + worktreeSettings?: WorktreeSettings; } export async function loadCliConfig( @@ -438,6 +493,9 @@ export async function loadCliConfig( const { cwd = process.cwd(), projectHooks } = options; const debugMode = isDebugMode(argv); + const worktreeSettings = + options.worktreeSettings ?? (await resolveWorktreeSettings(cwd)); + if (argv.sandbox) { process.env['GEMINI_SANDBOX'] = 'true'; } @@ -809,6 +867,7 @@ export async function loadCliConfig( importFormat: settings.context?.importFormat, debugMode, question, + worktreeSettings, coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, @@ -951,3 +1010,48 @@ function mergeExcludeTools( ]); return Array.from(allExcludeTools); } + +async function resolveWorktreeSettings( + cwd: string, +): Promise { + let worktreePath: string | undefined; + try { + const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], { + cwd, + }); + const toplevel = stdout.trim(); + const projectRoot = await getProjectRootForWorktree(toplevel); + + if (isGeminiWorktree(toplevel, projectRoot)) { + worktreePath = toplevel; + } + } catch (_e) { + return undefined; + } + + if (!worktreePath) { + return undefined; + } + + let worktreeBaseSha: string | undefined; + try { + const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { + cwd: worktreePath, + }); + worktreeBaseSha = stdout.trim(); + } catch (e: unknown) { + debugLogger.debug( + `Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + + if (!worktreeBaseSha) { + return undefined; + } + + return { + name: path.basename(worktreePath), + path: worktreePath, + baseSha: worktreeBaseSha, + }; +} diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts index 67636d922e..6c20737be9 100644 --- a/packages/cli/src/config/extension-manager.test.ts +++ b/packages/cli/src/config/extension-manager.test.ts @@ -637,64 +637,4 @@ describe('ExtensionManager', () => { ); }); }); - - describe('orphaned extension cleanup', () => { - it('should remove broken extension metadata on startup to allow re-installation', async () => { - const extName = 'orphaned-ext'; - const sourceDir = path.join(tempHomeDir, 'valid-source'); - fs.mkdirSync(sourceDir, { recursive: true }); - fs.writeFileSync( - path.join(sourceDir, 'gemini-extension.json'), - JSON.stringify({ name: extName, version: '1.0.0' }), - ); - - // Link an extension successfully. - await extensionManager.loadExtensions(); - await extensionManager.installOrUpdateExtension({ - source: sourceDir, - type: 'link', - }); - - const destinationPath = path.join(userExtensionsDir, extName); - const metadataPath = path.join( - destinationPath, - '.gemini-extension-install.json', - ); - expect(fs.existsSync(metadataPath)).toBe(true); - - // Simulate metadata corruption (e.g., pointing to a non-existent source). - fs.writeFileSync( - metadataPath, - JSON.stringify({ source: '/NON_EXISTENT_PATH', type: 'link' }), - ); - - // Simulate CLI startup. The manager should detect the broken link - // and proactively delete the orphaned metadata directory. - const newManager = new ExtensionManager({ - settings: createTestMergedSettings(), - workspaceDir: tempWorkspaceDir, - requestConsent: vi.fn().mockResolvedValue(true), - requestSetting: null, - integrityManager: mockIntegrityManager, - }); - - await newManager.loadExtensions(); - - // Verify the extension failed to load and was proactively cleaned up. - expect(newManager.getExtensions().some((e) => e.name === extName)).toBe( - false, - ); - expect(fs.existsSync(destinationPath)).toBe(false); - - // Verify the system is self-healed and allows re-linking to the valid source. - await newManager.installOrUpdateExtension({ - source: sourceDir, - type: 'link', - }); - - expect(newManager.getExtensions().some((e) => e.name === extName)).toBe( - true, - ); - }); - }); }); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index dd37d0ea1b..04487bc5f8 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -982,18 +982,11 @@ Would you like to attempt to install via "git clone" instead?`, plan: config.plan, }; } catch (e) { - const extName = path.basename(extensionDir); - debugLogger.warn( - `Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`, + debugLogger.error( + `Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage( + e, + )}`, ); - try { - await fs.promises.rm(extensionDir, { recursive: true, force: true }); - } catch (rmError) { - debugLogger.error( - `Failed to remove broken extension directory ${extensionDir}:`, - rmError, - ); - } return null; } } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index fa957d8f7f..ef7e61cf25 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -249,8 +249,10 @@ describe('extension tests', () => { expect(extensions[0].name).toBe('test-extension'); }); - it('should log a warning and remove the extension if a context file path is outside the extension directory', async () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('should skip the extension if a context file path is outside the extension directory and log an error', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, name: 'traversal-extension', @@ -660,8 +662,10 @@ name = "yolo-checker" expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}'); }); - it('should remove an extension with invalid JSON config and log a warning', async () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('should skip an extension with invalid JSON config and log an error', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); // Good extension createExtension({ @@ -682,15 +686,17 @@ name = "yolo-checker" expect(extensions[0].name).toBe('good-ext'); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( - `Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`, + `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`, ), ); consoleSpy.mockRestore(); }); - it('should remove an extension with missing "name" in config and log a warning', async () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('should skip an extension with missing "name" in config and log an error', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); // Good extension createExtension({ @@ -711,7 +717,7 @@ name = "yolo-checker" expect(extensions[0].name).toBe('good-ext'); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining( - `Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, + `Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`, ), ); @@ -737,8 +743,10 @@ name = "yolo-checker" expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined(); }); - it('should log a warning for invalid extension names during loading', async () => { - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + it('should log an error for invalid extension names during loading', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, name: 'bad_name', diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts index 76d7227ab4..8de884cdd5 100644 --- a/packages/cli/src/config/extensions/consent.test.ts +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -59,8 +59,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); async function expectConsentSnapshot(consentString: string) { - const renderResult = render(React.createElement(Text, null, consentString)); - await renderResult.waitUntilReady(); + const renderResult = await render( + React.createElement(Text, null, consentString), + ); await expect(renderResult).toMatchSvgSnapshot(); } diff --git a/packages/cli/src/config/mcp/mcpServerEnablement.test.ts b/packages/cli/src/config/mcp/mcpServerEnablement.test.ts index 8b41324790..12b483d59d 100644 --- a/packages/cli/src/config/mcp/mcpServerEnablement.test.ts +++ b/packages/cli/src/config/mcp/mcpServerEnablement.test.ts @@ -13,6 +13,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, Storage: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...actual.Storage, getGlobalGeminiDir: () => '/virtual-home/.gemini', }, diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index beecd6a017..984bdb8d60 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -632,6 +632,10 @@ export function resetSettingsCacheForTesting() { settingsCache.clear(); } +export function isWorktreeEnabled(settings: LoadedSettings): boolean { + return settings.merged.experimental.worktrees; +} + /** * Loads settings from user and workspace directories. * Project settings override user settings. diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 37ddf87642..c358cd65aa 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -538,8 +538,32 @@ describe('SettingsSchema', () => { } }; + const visitJsonSchema = (jsonSchema: Record) => { + const ref = jsonSchema['ref']; + if (typeof ref === 'string') { + referenced.add(ref); + } + const properties = jsonSchema['properties']; + if ( + properties && + typeof properties === 'object' && + !Array.isArray(properties) + ) { + Object.values(properties as Record).forEach((prop) => + visitJsonSchema(prop as Record), + ); + } + const items = jsonSchema['items']; + if (items && typeof items === 'object' && !Array.isArray(items)) { + visitJsonSchema(items as Record); + } + }; + Object.values(schema).forEach(visitDefinition); + // Also visit all definitions to find nested references + Object.values(SETTINGS_SCHEMA_DEFINITIONS).forEach(visitJsonSchema); + // Ensure definitions map doesn't accumulate stale entries. Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => { if (!referenced.has(key)) { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index f1711f3b92..277dcfdcb9 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1094,7 +1094,7 @@ const SETTINGS_SCHEMA = { showInDialog: false, additionalProperties: { type: 'array', - ref: 'ModelPolicy', + ref: 'ModelPolicyChain', }, }, }, @@ -1198,6 +1198,26 @@ const SETTINGS_SCHEMA = { 'Disable user input on browser window during automation.', showInDialog: false, }, + confirmSensitiveActions: { + type: 'boolean', + label: 'Confirm Sensitive Actions', + category: 'Advanced', + requiresRestart: true, + default: false, + description: + 'Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script).', + showInDialog: true, + }, + blockFileUploads: { + type: 'boolean', + label: 'Block File Uploads', + category: 'Advanced', + requiresRestart: true, + default: false, + description: + 'Hard-block file upload requests from the browser agent.', + showInDialog: true, + }, }, }, }, @@ -1906,6 +1926,16 @@ const SETTINGS_SCHEMA = { description: 'Enable local and remote subagents.', showInDialog: false, }, + worktrees: { + type: 'boolean', + label: 'Enable Git Worktrees', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable automated Git worktree management for parallel work.', + showInDialog: true, + }, extensionManagement: { type: 'boolean', label: 'Extension Management', @@ -2988,6 +3018,14 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, + ModelPolicyChain: { + type: 'array', + description: 'A chain of model policies for fallback behavior.', + items: { + type: 'object', + ref: 'ModelPolicy', + }, + }, ModelPolicy: { type: 'object', description: diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index d9f3353dce..69c843b34b 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -126,6 +126,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { clearInstance: vi.fn(), }, coreEvents: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...actual.coreEvents, emitFeedback: vi.fn(), emitConsoleLog: vi.fn(), @@ -199,6 +200,8 @@ vi.mock('./config/config.js', () => ({ networkAccess: false, }), isDebugMode: vi.fn(() => false), + getRequestedWorktreeName: vi.fn(() => undefined), + getWorktreeArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ @@ -1507,6 +1510,7 @@ describe('startInteractiveUI', () => { .spyOn(process.stdout, 'write') .mockImplementation(() => true); const mockConfigWithScreenReader = { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...mockConfig, getScreenReader: () => screenReader, } as Config; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index cebf839a5e..eec6dc6290 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -9,6 +9,7 @@ import { WarningPriority, type Config, type ResumedSessionData, + type WorktreeInfo, type OutputPayload, type ConsoleLogPayload, type UserFeedbackPayload, @@ -63,6 +64,7 @@ import { registerTelemetryConfig, setupSignalHandlers, } from './utils/cleanup.js'; +import { setupWorktree } from './utils/worktreeSetup.js'; import { cleanupToolOutputFiles, cleanupExpiredSessions, @@ -210,6 +212,13 @@ export async function main() { const settings = loadSettings(); loadSettingsHandle?.end(); + // If a worktree is requested and enabled, set it up early. + const requestedWorktree = cliConfig.getRequestedWorktreeName(settings); + let worktreeInfo: WorktreeInfo | undefined; + if (requestedWorktree !== undefined) { + worktreeInfo = await setupWorktree(requestedWorktree || undefined); + } + // Report settings errors once during startup settings.errors.forEach((error) => { coreEvents.emitFeedback('warning', error.message); @@ -433,6 +442,7 @@ export async function main() { const loadConfigHandle = startupProfiler.start('load_cli_config'); const config = await loadCliConfig(settings.merged, sessionId, argv, { projectHooks: settings.workspace.settings.hooks, + worktreeSettings: worktreeInfo, }); loadConfigHandle?.end(); diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 9be9fc6194..382ad3f81f 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -72,6 +72,8 @@ vi.mock('./config/config.js', () => ({ } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), + getRequestedWorktreeName: vi.fn(() => undefined), + getWorktreeArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 206d011e63..4e45b0f188 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -1137,6 +1137,7 @@ describe('runNonInteractive', () => { expect( processStderrSpy.mock.calls.some( + // eslint-disable-next-line no-restricted-syntax (call) => typeof call[0] === 'string' && call[0].includes('Cancelling'), ), ).toBe(true); diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index b5e7856711..f166c161cd 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -266,6 +266,7 @@ describe('BuiltinCommandLoader', () => { it('should include policies command when message bus integration is enabled', async () => { const mockConfigWithMessageBus = { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...mockConfig, getEnableHooks: () => false, getMcpEnabled: () => true, diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts index ae9b44ee44..d34576cf3f 100644 --- a/packages/cli/src/test-utils/customMatchers.ts +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -79,7 +79,7 @@ export async function toMatchSvgSnapshot( } function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment + // eslint-disable-next-line @typescript-eslint/no-explicit-any const { isNot } = this as any; let pass = true; const invalidLines: Array<{ line: number; content: string }> = []; @@ -108,7 +108,6 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { }; } -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion expect.extend({ toHaveOnlyValidCharacters, toMatchSvgSnapshot, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 15e6422e1a..6eda7f3109 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -37,14 +37,12 @@ export const createMockCommandContext = ( }, services: { agentContext: null, - settings: { merged: defaultMergedSettings, setValue: vi.fn(), forScope: vi.fn().mockReturnValue({ settings: {} }), } as unknown as LoadedSettings, git: undefined as GitService | undefined, - logger: { log: vi.fn(), logMessage: vi.fn(), @@ -53,7 +51,6 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // Cast because Logger is a class. }, - ui: { addItem: vi.fn(), clear: vi.fn(), @@ -72,7 +69,6 @@ export const createMockCommandContext = ( } as any, session: { sessionShellAllowlist: new Set(), - stats: { sessionStartTime: new Date(), lastPromptTokenCount: 0, @@ -98,7 +94,6 @@ export const createMockCommandContext = ( for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { const sourceValue = source[key]; - const targetValue = output[key]; if ( @@ -109,7 +104,6 @@ export const createMockCommandContext = ( output[key] = merge(targetValue, sourceValue); } else { // If not, we do a direct assignment. This preserves Date objects and others. - output[key] = sourceValue; } } diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index d4f11212e3..e1505df970 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -44,6 +44,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getDeleteSession: vi.fn(() => undefined), setSessionId: vi.fn(), getSessionId: vi.fn().mockReturnValue('mock-session-id'), + getWorktreeSettings: vi.fn(() => undefined), getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })), getAcpMode: vi.fn(() => false), isBrowserLaunchSuppressed: vi.fn(() => false), diff --git a/packages/cli/src/test-utils/render.test.tsx b/packages/cli/src/test-utils/render.test.tsx index 7172a99119..3c3f4102a4 100644 --- a/packages/cli/src/test-utils/render.test.tsx +++ b/packages/cli/src/test-utils/render.test.tsx @@ -12,24 +12,18 @@ import { waitFor } from './async.js'; describe('render', () => { it('should render a component', async () => { - const { lastFrame, waitUntilReady, unmount } = render( - Hello World, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await render(Hello World); expect(lastFrame()).toBe('Hello World\n'); unmount(); }); it('should support rerender', async () => { - const { lastFrame, rerender, waitUntilReady, unmount } = render( + const { lastFrame, rerender, waitUntilReady, unmount } = await render( Hello, ); - await waitUntilReady(); expect(lastFrame()).toBe('Hello\n'); - await act(async () => { - rerender(World); - }); + await act(async () => rerender(World)); await waitUntilReady(); expect(lastFrame()).toBe('World\n'); unmount(); @@ -42,10 +36,8 @@ describe('render', () => { return Hello; } - const { unmount, waitUntilReady } = render(); - await waitUntilReady(); + const { unmount } = await render(); unmount(); - expect(cleanupMock).toHaveBeenCalled(); }); }); @@ -54,36 +46,27 @@ describe('renderHook', () => { it('should rerender with previous props when called without arguments', async () => { const useTestHook = ({ value }: { value: number }) => { const [count, setCount] = useState(0); - useEffect(() => { - setCount((c) => c + 1); - }, [value]); + useEffect(() => setCount((c) => c + 1), [value]); return { count, value }; }; - const { result, rerender, waitUntilReady, unmount } = renderHook( + const { result, rerender, waitUntilReady, unmount } = await renderHook( useTestHook, - { - initialProps: { value: 1 }, - }, + { initialProps: { value: 1 } }, ); - await waitUntilReady(); expect(result.current.value).toBe(1); await waitFor(() => expect(result.current.count).toBe(1)); // Rerender with new props - await act(async () => { - 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 - await act(async () => { - rerender(); - }); + await act(async () => rerender()); await waitUntilReady(); expect(result.current.value).toBe(2); // Count should not increase because value didn't change @@ -98,14 +81,11 @@ describe('renderHook', () => { }; const { result, rerender, waitUntilReady, unmount } = - renderHook(useTestHook); - await waitUntilReady(); + await renderHook(useTestHook); expect(result.current.count).toBe(0); - await act(async () => { - rerender(); - }); + await act(async () => rerender()); await waitUntilReady(); expect(result.current.count).toBe(0); unmount(); @@ -113,19 +93,14 @@ describe('renderHook', () => { it('should update props if undefined is passed explicitly', async () => { const useTestHook = (val: string | undefined) => val; - const { result, rerender, waitUntilReady, unmount } = renderHook( + const { result, rerender, waitUntilReady, unmount } = await renderHook( useTestHook, - { - initialProps: 'initial' as string | undefined, - }, + { initialProps: 'initial' }, ); - await waitUntilReady(); expect(result.current).toBe('initial'); - await act(async () => { - 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 7d298b120d..04a642d687 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -257,13 +257,9 @@ class XtermStdout extends EventEmitter { 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 === '' - ) { + // If Ink expects nothing (no new static content and no dynamic output), + // we consider it a match because the terminal buffer will just hold the historical static content. + if (expectedFrame === '') { return true; } @@ -271,8 +267,8 @@ class XtermStdout extends EventEmitter { return false; } - // If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match. - if (expectedFrame === '' || currentFrame === '') { + // If the terminal is empty but Ink expects something, it's not a match. + if (currentFrame === '') { return false; } @@ -380,15 +376,21 @@ export type RenderInstance = { capturedOverflowActions: OverflowActions | undefined; }; +export type RenderWithProvidersInstance = RenderInstance & { + simulateClick: ( + col: number, + row: number, + button?: 0 | 1 | 2, + ) => Promise; +}; + const instances: InkInstance[] = []; -// Wrapper around ink's render that ensures act() is called and uses Xterm for output -export const render = ( +export const render = async ( tree: React.ReactElement, terminalWidth?: number, -): Omit< - RenderInstance, - 'capturedOverflowState' | 'capturedOverflowActions' +): Promise< + Omit > => { const cols = terminalWidth ?? 100; // We use 1000 rows to avoid windows with incorrect snapshots if a correct @@ -437,6 +439,8 @@ export const render = ( instances.push(instance); + await stdout.waitUntilReady(); + return { rerender: (newTree: React.ReactElement) => { act(() => { @@ -622,15 +626,7 @@ export const renderWithProviders = async ( }; appState?: AppState; } = {}, -): Promise< - RenderInstance & { - simulateClick: ( - col: number, - row: number, - button?: 0 | 1 | 2, - ) => Promise; - } -> => { +): Promise => { const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, { @@ -751,7 +747,10 @@ export const renderWithProviders = async ( ); - const renderResult = render(wrapWithProviders(component), terminalWidth); + const renderResult = await render( + wrapWithProviders(component), + terminalWidth, + ); return { ...renderResult, @@ -765,21 +764,20 @@ export const renderWithProviders = async ( }; }; -export function renderHook( +export async function renderHook( renderCallback: (props: Props) => Result, options?: { initialProps?: Props; wrapper?: React.ComponentType<{ children: React.ReactNode }>; }, -): { +): Promise<{ result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; generateSvg: () => string; -} { +}> { const result = { current: undefined as unknown as Result }; - let currentProps = options?.initialProps as Props; function TestComponent({ @@ -800,17 +798,15 @@ export function renderHook( let waitUntilReady: () => Promise = async () => {}; let generateSvg: () => string = () => ''; - act(() => { - const renderResult = render( - - - , - ); - inkRerender = renderResult.rerender; - unmount = renderResult.unmount; - waitUntilReady = renderResult.waitUntilReady; - generateSvg = renderResult.generateSvg; - }); + const renderResult = await render( + + + , + ); + inkRerender = renderResult.rerender; + unmount = renderResult.unmount; + waitUntilReady = renderResult.waitUntilReady; + generateSvg = renderResult.generateSvg; function rerender(props?: Props) { if (arguments.length > 0) { @@ -864,7 +860,7 @@ export async function renderHookWithProviders( const Wrapper = options.wrapper || (({ children }) => <>{children}); - let renderResult: ReturnType; + let renderResult: RenderWithProvidersInstance; await act(async () => { renderResult = await renderWithProviders( diff --git a/packages/cli/src/test-utils/settings.ts b/packages/cli/src/test-utils/settings.ts index ab2420849d..20d0613f83 100644 --- a/packages/cli/src/test-utils/settings.ts +++ b/packages/cli/src/test-utils/settings.ts @@ -46,7 +46,6 @@ export const createMockSettings = ( workspace, isTrusted, errors, - merged: mergedOverride, ...settingsOverrides } = overrides; @@ -61,7 +60,6 @@ export const createMockSettings = ( settings: settingsOverrides, originalSettings: settingsOverrides, }, - (workspace as any) || { path: '', settings: {}, originalSettings: {} }, isTrusted ?? true, errors || [], diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 7f5e55c022..950363f6a8 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -94,14 +94,10 @@ describe('App', () => { }; it('should render main content and composer when not quitting', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -115,14 +111,10 @@ describe('App', () => { quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: quittingUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: quittingUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }); expect(lastFrame()).toContain('Quitting...'); unmount(); @@ -136,14 +128,10 @@ describe('App', () => { pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: quittingUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: quittingUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('HistoryItemDisplay'); expect(lastFrame()).toContain('Quitting...'); @@ -156,14 +144,10 @@ describe('App', () => { dialogsVisible: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: dialogUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: dialogUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -183,14 +167,10 @@ describe('App', () => { [stateKey]: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`); unmount(); @@ -200,14 +180,10 @@ describe('App', () => { it('should render ScreenReaderAppLayout when screen reader is enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Footer'); @@ -219,14 +195,10 @@ describe('App', () => { it('should render DefaultAppLayout when screen reader is not enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -274,15 +246,11 @@ describe('App', () => { vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: stateWithConfirmingTool, - config: configWithExperiment, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: stateWithConfirmingTool, + config: configWithExperiment, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -295,28 +263,20 @@ describe('App', () => { describe('Snapshots', () => { it('renders default layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders screen reader layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toMatchSnapshot(); unmount(); }); @@ -326,14 +286,10 @@ describe('App', () => { ...mockUIState, dialogsVisible: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: dialogUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: dialogUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 650804025b..313573a573 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -16,7 +16,7 @@ import { } from 'vitest'; import { render, cleanup, persistentStateMock } from '../test-utils/render.js'; import { waitFor } from '../test-utils/async.js'; -import { act, useContext, type ReactElement } from 'react'; +import { act, useContext } from 'react'; import { AppContainer } from './AppContainer.js'; import { SettingsContext } from './contexts/SettingsContext.js'; import { type TrackedToolCall } from './hooks/useToolScheduler.js'; @@ -250,6 +250,15 @@ describe('AppContainer State Management', () => { let mockInitResult: InitializationResult; let mockExtensionManager: MockedObject; + type AppContainerProps = { + settings?: LoadedSettings; + config?: Config; + version?: string; + initResult?: InitializationResult; + startupWarnings?: StartupWarning[]; + resumedSessionData?: ResumedSessionData; + }; + // Helper to generate the AppContainer JSX for render and rerender const getAppContainer = ({ settings = mockSettings, @@ -258,14 +267,7 @@ describe('AppContainer State Management', () => { initResult = mockInitResult, startupWarnings, resumedSessionData, - }: { - settings?: LoadedSettings; - config?: Config; - version?: string; - initResult?: InitializationResult; - startupWarnings?: StartupWarning[]; - resumedSessionData?: ResumedSessionData; - } = {}) => ( + }: AppContainerProps = {}) => ( @@ -282,7 +284,7 @@ describe('AppContainer State Management', () => { ); // Helper to render the AppContainer - const renderAppContainer = (props?: Parameters[0]) => + const renderAppContainer = async (props?: AppContainerProps) => render(getAppContainer(props)); // Create typed mocks for all hooks @@ -514,13 +516,9 @@ describe('AppContainer State Management', () => { describe('Basic Rendering', () => { it('renders without crashing with minimal props', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('renders with startup warnings', async () => { @@ -537,44 +535,32 @@ describe('AppContainer State Management', () => { }, ]; - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ startupWarnings }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ startupWarnings }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('shows full UI details by default', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState.cleanUiDetailsVisible).toBe(true); - }); - unmount!(); + expect(capturedUIState.cleanUiDetailsVisible).toBe(true); + unmount(); }); it('starts in minimal UI mode when Focus UI preference is persisted', async () => { persistentStateMock.get.mockReturnValueOnce(true); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: mockSettings, - }); - unmount = result.unmount; - }); + }), + ); - await waitFor(() => { - expect(capturedUIState.cleanUiDetailsVisible).toBe(false); - }); + expect(capturedUIState.cleanUiDetailsVisible).toBe(false); expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled'); - unmount!(); + unmount(); }); }); @@ -609,15 +595,9 @@ describe('AppContainer State Management', () => { ], }); - let unmount: (() => void) | undefined; - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => - expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), - ); + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); expect( terminalNotificationsMocks.buildRunEventNotificationContent, ).toHaveBeenCalledWith( @@ -626,9 +606,7 @@ describe('AppContainer State Management', () => { }), ); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('does not send attention notification when terminal is focused', async () => { @@ -661,19 +639,13 @@ describe('AppContainer State Management', () => { ], }); - let unmount: (() => void) | undefined; - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); expect( terminalNotificationsMocks.notifyViaTerminal, ).not.toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('sends attention notification when focus reporting is unavailable', async () => { @@ -706,19 +678,11 @@ describe('AppContainer State Management', () => { ], }); - let unmount: (() => void) | undefined; - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => - expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), - ); + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('sends a macOS notification when a response completes while unfocused', async () => { @@ -732,35 +696,24 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; - - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); + const { unmount, rerender } = await act(async () => renderAppContainer()); currentStreamingState = 'idle'; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); - await waitFor(() => - expect( - terminalNotificationsMocks.buildRunEventNotificationContent, - ).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'session_complete', - detail: 'Gemini CLI finished responding.', - }), - ), + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), ); expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('sends completion notification when focus reporting is unavailable', async () => { @@ -774,34 +727,23 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; - - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); + const { unmount, rerender } = await act(async () => renderAppContainer()); currentStreamingState = 'idle'; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); - await waitFor(() => - expect( - terminalNotificationsMocks.buildRunEventNotificationContent, - ).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'session_complete', - detail: 'Gemini CLI finished responding.', - }), - ), + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), ); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('does not send completion notification when another action-required dialog is pending', async () => { @@ -819,27 +761,18 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; - - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); + const { unmount, rerender } = await act(async () => renderAppContainer()); currentStreamingState = 'idle'; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); expect( terminalNotificationsMocks.notifyViaTerminal, ).not.toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('can send repeated attention notifications for the same key after pending state clears', async () => { @@ -875,24 +808,15 @@ describe('AppContainer State Management', () => { pendingHistoryItems, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; + const { unmount, rerender } = await act(async () => renderAppContainer()); - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); - - await waitFor(() => - expect( - terminalNotificationsMocks.notifyViaTerminal, - ).toHaveBeenCalledTimes(1), - ); + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(1); pendingHistoryItems = []; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); pendingHistoryItems = [ @@ -917,18 +841,14 @@ describe('AppContainer State Management', () => { }, ]; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); - await waitFor(() => - expect( - terminalNotificationsMocks.notifyViaTerminal, - ).toHaveBeenCalledTimes(2), - ); + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(2); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('initializes with theme error from initialization result', async () => { @@ -937,68 +857,53 @@ describe('AppContainer State Management', () => { themeError: 'Failed to load theme', }; - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ initResult: initResultWithError, - }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); - it('handles debug mode state', () => { + it('handles debug mode state', async () => { const debugConfig = makeFakeConfig(); vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true); - expect(() => { - renderAppContainer({ config: debugConfig }); - }).not.toThrow(); + const { unmount } = await act(async () => + renderAppContainer({ config: debugConfig }), + ); + unmount(); }); }); describe('Context Providers', () => { it('provides AppContext with correct values', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ version: '2.0.0' }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => + renderAppContainer({ version: '2.0.0' }), + ); + expect(capturedUIState).toBeTruthy(); // Should render and unmount cleanly - expect(() => unmount!()).not.toThrow(); + unmount(); }); it('provides UIStateContext with state management', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('provides UIActionsContext with action handlers', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('provides ConfigContext with config object', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); }); @@ -1011,13 +916,11 @@ describe('AppContainer State Management', () => { showMemoryUsage: false, }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ settings: settingsAllHidden }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ settings: settingsAllHidden }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('handles settings with memory usage enabled', async () => { @@ -1025,13 +928,11 @@ describe('AppContainer State Management', () => { showMemoryUsage: true, }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ settings: settingsWithMemory }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ settings: settingsWithMemory }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); }); @@ -1039,13 +940,11 @@ describe('AppContainer State Management', () => { it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( 'handles version format: %s', async (version) => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ version }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ version }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }, ); }); @@ -1058,30 +957,30 @@ describe('AppContainer State Management', () => { }); // Should still render without crashing - errors should be handled internally - const { unmount } = renderAppContainer({ config: errorConfig }); + const { unmount } = await act(async () => + renderAppContainer({ config: errorConfig }), + ); unmount(); }); it('handles undefined settings gracefully', async () => { const undefinedSettings = createMockSettings(); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ settings: undefinedSettings }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ settings: undefinedSettings }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); }); describe('Provider Hierarchy', () => { - it('establishes correct provider nesting order', () => { + it('establishes correct provider nesting order', async () => { // This tests that all the context providers are properly nested // and that the component tree can be built without circular dependencies - const { unmount } = renderAppContainer(); + const { unmount } = await act(async () => renderAppContainer()); - expect(() => unmount()).not.toThrow(); + unmount(); }); }); @@ -1113,40 +1012,32 @@ describe('AppContainer State Management', () => { filePath: '/tmp/test-session.json', }; - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ config: mockConfig, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: mockResumedSessionData, - }); - unmount = result.unmount; - }); - await act(async () => { - unmount(); - }); + }), + ); + unmount(); }); it('renders without resumed session data', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ config: mockConfig, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: undefined, - }); - unmount = result.unmount; - }); - await act(async () => { - unmount(); - }); + }), + ); + unmount(); }); - it('initializes chat recording service when config has it', () => { + it('initializes chat recording service when config has it', async () => { const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), @@ -1166,18 +1057,19 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - expect(() => { + const { unmount } = await act(async () => renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, - }); - }).not.toThrow(); + }), + ); + unmount(); }); }); describe('Session Recording Integration', () => { - it('provides chat recording service configuration', () => { + it('provides chat recording service configuration', async () => { const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), @@ -1203,23 +1095,24 @@ describe('AppContainer State Management', () => { 'test-session-123', ); - expect(() => { + const { unmount } = await act(async () => renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, - }); - }).not.toThrow(); + }), + ); // Verify the recording service structure is correct expect(configWithRecording.getGeminiClient).toBeDefined(); expect(mockGeminiClient.getChatRecordingService).toBeDefined(); expect(mockChatRecordingService.initialize).toBeDefined(); expect(mockChatRecordingService.recordMessage).toBeDefined(); + unmount(); }); - it('handles session recording when messages are added', () => { + it('handles session recording when messages are added', async () => { const mockRecordMessage = vi.fn(); const mockRecordMessageTokens = vi.fn(); @@ -1242,22 +1135,25 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - renderAppContainer({ - config: configWithRecording, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - }); + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }), + ); // The actual recording happens through the useHistory hook // which would be triggered by user interactions expect(mockChatRecordingService.initialize).toBeDefined(); expect(mockChatRecordingService.recordMessage).toBeDefined(); + unmount(); }); }); describe('Session Resume Flow', () => { - it('accepts resumed session data', () => { + it('accepts resumed session data', async () => { const mockResumeChat = vi.fn(); const mockGeminiClient = { isInitialized: vi.fn(() => true), @@ -1303,22 +1199,23 @@ describe('AppContainer State Management', () => { filePath: '/tmp/resumed-session.json', }; - expect(() => { + const { unmount } = await act(async () => renderAppContainer({ config: configWithClient, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: resumedData, - }); - }).not.toThrow(); + }), + ); // Verify the resume functionality structure is in place expect(mockGeminiClient.resumeChat).toBeDefined(); expect(resumedData.conversation.messages).toHaveLength(2); + unmount(); }); - it('does not attempt resume when client is not initialized', () => { + it('does not attempt resume when client is not initialized', async () => { const mockResumeChat = vi.fn(); const mockGeminiClient = { isInitialized: vi.fn(() => false), // Not initialized @@ -1343,21 +1240,24 @@ describe('AppContainer State Management', () => { filePath: '/tmp/session.json', }; - renderAppContainer({ - config: configWithClient, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - resumedSessionData: resumedData, - }); + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithClient, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: resumedData, + }), + ); // Should not call resumeChat when client is not initialized expect(mockResumeChat).not.toHaveBeenCalled(); + unmount(); }); }); describe('Token Counting from Session Stats', () => { - it('tracks token counts from session messages', () => { + it('tracks token counts from session messages', async () => { // Session stats are provided through the SessionStatsProvider context // in the real app, not through the config directly const mockChatRecordingService = { @@ -1385,33 +1285,30 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - renderAppContainer({ - config: configWithRecording, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - }); + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }), + ); // In the actual app, these stats would be displayed in components // and updated as messages are processed through the recording service expect(mockChatRecordingService.recordMessageTokens).toBeDefined(); expect(mockChatRecordingService.getCurrentConversation).toBeDefined(); + unmount(); }); }); describe('Quota and Fallback Integration', () => { it('passes a null proQuotaRequest to UIStateContext by default', async () => { // The default mock from beforeEach already sets proQuotaRequest to null - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => { - // Assert that the context value is as expected - expect(capturedUIState.quota.proQuotaRequest).toBeNull(); - }); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + // Assert that the context value is as expected + expect(capturedUIState.quota.proQuotaRequest).toBeNull(); + unmount(); }); it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => { @@ -1427,16 +1324,10 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => { - // Assert: The mock request is correctly passed through the context - expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); - }); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + // Assert: The mock request is correctly passed through the context + expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); + unmount(); }); it('passes the handleProQuotaChoice function to UIActionsContext', async () => { @@ -1448,22 +1339,16 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => { - // Assert: The action in the context is the mock handler we provided - expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); - }); + const { unmount } = await act(async () => renderAppContainer()); + // Assert: The action in the context is the mock handler we provided + expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); // You can even verify that the plumbed function is callable act(() => { capturedUIActions.handleProQuotaChoice('retry_later'); }); expect(mockHandler).toHaveBeenCalledWith('retry_later'); - unmount!(); + unmount(); }); }); @@ -1479,7 +1364,7 @@ describe('AppContainer State Management', () => { expect(stdout).toBe(mocks.mockStdout); }); - it('should update terminal title with Working… when showStatusInTitle is false', () => { + it('should update terminal title with Working… when showStatusInTitle is false', async () => { // Arrange: Set up mock settings with showStatusInTitle disabled const mockSettingsWithShowStatusFalse = createMockSettings({ ui: { @@ -1496,9 +1381,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithShowStatusFalse, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithShowStatusFalse, + }), + ); // Assert: Check that title was updated with "Working…" const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1512,7 +1399,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use legacy terminal title when dynamicWindowTitle is false', () => { + it('should use legacy terminal title when dynamicWindowTitle is false', async () => { // Arrange: Set up mock settings with dynamicWindowTitle disabled const mockSettingsWithDynamicTitleFalse = createMockSettings({ ui: { @@ -1529,9 +1416,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithDynamicTitleFalse, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithDynamicTitleFalse, + }), + ); // Assert: Check that legacy title was used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1545,7 +1434,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should not update terminal title when hideWindowTitle is true', () => { + it('should not update terminal title when hideWindowTitle is true', async () => { // Arrange: Set up mock settings with hideWindowTitle enabled const mockSettingsWithHideTitleTrue = createMockSettings({ ui: { @@ -1555,9 +1444,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithHideTitleTrue, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithHideTitleTrue, + }), + ); // Assert: Check that no title-related writes occurred const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1568,7 +1459,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should update terminal title with thought subject when in active state', () => { + it('should update terminal title with thought subject when in active state', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { @@ -1586,9 +1477,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that title was updated with thought subject and suffix const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1602,7 +1495,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should update terminal title with default text when in Idle state and no thought subject', () => { + it('should update terminal title with default text when in Idle state and no thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { @@ -1615,9 +1508,11 @@ describe('AppContainer State Management', () => { mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that title was updated with default Idle text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1649,13 +1544,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: mockSettingsWithTitleEnabled, - }); - unmount = result.unmount; - }); + }), + ); // Assert: Check that title was updated with confirmation text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1666,7 +1559,7 @@ describe('AppContainer State Management', () => { expect(titleWrites[0][0]).toBe( `\x1b]0;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`, ); - unmount!(); + unmount(); }); describe('Shell Focus Action Required', () => { @@ -1712,9 +1605,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); // Act: Render the container (embeddedShellFocused is false by default in state) - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Initially it should show the working status const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1773,9 +1668,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Fast-forward time by 65 seconds - should still NOT be Action Required await act(async () => { @@ -1830,9 +1727,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Fast-forward time by 65 seconds await act(async () => { @@ -1875,9 +1774,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); // Act: Render the container - const { unmount, rerender } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount, rerender } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Fast-forward time by 20 seconds await act(async () => { @@ -1931,7 +1832,7 @@ describe('AppContainer State Management', () => { }); }); - it('should pad title to exactly 80 characters', () => { + it('should pad title to exactly 80 characters', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { @@ -1949,9 +1850,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that title is padded to exactly 80 characters const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1966,7 +1869,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use correct ANSI escape code format', () => { + it('should use correct ANSI escape code format', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { @@ -1984,9 +1887,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that the correct ANSI escape sequence is used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1999,7 +1904,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use CLI_TITLE environment variable when set', () => { + it('should use CLI_TITLE environment variable when set', async () => { // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) const mockSettingsWithTitleDisabled = createMockSettings({ ui: { @@ -2018,9 +1923,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleDisabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleDisabled, + }), + ); // Assert: Check that title was updated with CLI_TITLE value const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -2046,7 +1953,7 @@ describe('AppContainer State Management', () => { }); it('should set and clear the queue error message after a timeout', async () => { - const { rerender, unmount } = renderAppContainer(); + const { rerender, unmount } = await act(async () => renderAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2068,7 +1975,7 @@ describe('AppContainer State Management', () => { }); it('should reset the timer if a new error message is set', async () => { - const { rerender, unmount } = renderAppContainer(); + const { rerender, unmount } = await act(async () => renderAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2110,11 +2017,11 @@ describe('AppContainer State Management', () => { let mockCancelOngoingRequest: Mock; let rerender: () => void; let unmount: () => void; - let stdin: ReturnType['stdin']; + let stdin: Awaited>['stdin']; // Helper function to reduce boilerplate in tests const setupKeypressTest = async () => { - const renderResult = renderAppContainer(); + const renderResult = await act(async () => renderAppContainer()); stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); @@ -2328,7 +2235,7 @@ describe('AppContainer State Management', () => { activePtyId: 1, }); - const renderResult = render(getAppContainer()); + const renderResult = await act(async () => render(getAppContainer())); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2446,7 +2353,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; const setupShortcutsVisibilityTest = async () => { - const renderResult = renderAppContainer(); + const renderResult = await act(async () => renderAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2522,9 +2429,7 @@ describe('AppContainer State Management', () => { await act(async () => { rerender(); }); - await waitFor(() => { - expect(capturedUIState.shortcutsHelpVisible).toBe(false); - }); + expect(capturedUIState.shortcutsHelpVisible).toBe(false); unmount(); }); @@ -2553,9 +2458,7 @@ describe('AppContainer State Management', () => { await act(async () => { rerender(); }); - await waitFor(() => { - expect(capturedUIState.shortcutsHelpVisible).toBe(false); - }); + expect(capturedUIState.shortcutsHelpVisible).toBe(false); unmount(); }); @@ -2564,7 +2467,7 @@ describe('AppContainer State Management', () => { describe('Copy Mode (CTRL+S)', () => { let rerender: () => void; let unmount: () => void; - let stdin: ReturnType['stdin']; + let stdin: Awaited>['stdin']; const setupCopyModeTest = async ( isAlternateMode = false, @@ -2602,7 +2505,7 @@ describe('AppContainer State Management', () => { ); - const renderResult = render(getTree(testSettings)); + const renderResult = await act(async () => render(getTree(testSettings))); stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); @@ -2792,15 +2695,10 @@ describe('AppContainer State Management', () => { closeModelDialog: vi.fn(), }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); expect(capturedUIState.isModelDialogOpen).toBe(true); - unmount!(); + unmount(); }); it('should provide model dialog actions in the UIActionsContext', async () => { @@ -2812,45 +2710,29 @@ describe('AppContainer State Management', () => { closeModelDialog: mockCloseModelDialog, }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); // Verify that the actions are correctly passed through context act(() => { capturedUIActions.closeModelDialog(); }); expect(mockCloseModelDialog).toHaveBeenCalled(); - unmount!(); + unmount(); }); }); describe('Agent Configuration Dialog Integration', () => { it('should initialize with dialog closed and no agent selected', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - + const { unmount } = await act(async () => renderAppContainer()); expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); expect(capturedUIState.selectedAgentName).toBeUndefined(); expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); - unmount!(); + unmount(); }); it('should update state when openAgentConfigDialog is called', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); const agentDefinition = { name: 'test-agent' }; act(() => { @@ -2865,16 +2747,11 @@ describe('AppContainer State Management', () => { expect(capturedUIState.selectedAgentName).toBe('test-agent'); expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent'); expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition); - unmount!(); + unmount(); }); it('should clear state when closeAgentConfigDialog is called', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); const agentDefinition = { name: 'test-agent' }; act(() => { @@ -2895,31 +2772,26 @@ describe('AppContainer State Management', () => { expect(capturedUIState.selectedAgentName).toBeUndefined(); expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); - unmount!(); + unmount(); }); }); describe('CoreEvents Integration', () => { it('subscribes to UserFeedback and drains backlog on mount', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); expect(mockCoreEvents.on).toHaveBeenCalledWith( CoreEvent.UserFeedback, expect.any(Function), ); expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); - unmount!(); + unmount(); }); it('unsubscribes from UserFeedback on unmount', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -2935,7 +2807,7 @@ describe('AppContainer State Management', () => { it('adds history item when UserFeedback event is received', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -2971,7 +2843,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { @@ -3004,7 +2876,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -3016,7 +2888,7 @@ describe('AppContainer State Management', () => { it('handles consent request events', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -3053,7 +2925,7 @@ describe('AppContainer State Management', () => { it('unsubscribes from ConsentRequest on unmount', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -3076,7 +2948,7 @@ describe('AppContainer State Management', () => { }); let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { @@ -3104,12 +2976,7 @@ describe('AppContainer State Management', () => { }); it('preserves buffer when cancelling, even if empty (user is in control)', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3122,7 +2989,7 @@ describe('AppContainer State Management', () => { // Should NOT modify buffer when cancelling - user is in control expect(mockSetText).not.toHaveBeenCalled(); - unmount!(); + unmount(); }); it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => { @@ -3140,12 +3007,7 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3159,7 +3021,7 @@ describe('AppContainer State Management', () => { // Should NOT call setText - prompt should be preserved regardless of content expect(mockSetText).not.toHaveBeenCalled(); - unmount!(); + unmount(); }); it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => { @@ -3170,14 +3032,8 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => - expect(capturedUIState.userMessages).toContain('previous message'), - ); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState.userMessages).toContain('previous message'); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3187,11 +3043,9 @@ describe('AppContainer State Management', () => { onCancelSubmit(true); }); - await waitFor(() => { - expect(mockSetText).toHaveBeenCalledWith('previous message'); - }); + expect(mockSetText).toHaveBeenCalledWith('previous message'); - unmount!(); + unmount(); }); it('input history is independent from conversation history (survives /clear)', async () => { @@ -3204,18 +3058,10 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - let rerender: (tree: ReactElement) => void; - let unmount; - await act(async () => { - const result = renderAppContainer(); - rerender = result.rerender; - unmount = result.unmount; - }); + const { rerender, unmount } = await act(async () => renderAppContainer()); // Verify userMessages is populated from inputHistory - await waitFor(() => - expect(capturedUIState.userMessages).toContain('first prompt'), - ); + expect(capturedUIState.userMessages).toContain('first prompt'); expect(capturedUIState.userMessages).toContain('second prompt'); // Clear the conversation history (simulating /clear command) @@ -3238,7 +3084,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.userMessages).toContain('first prompt'); expect(capturedUIState.userMessages).toContain('second prompt'); - unmount!(); + unmount(); }); }); @@ -3253,14 +3099,10 @@ describe('AppContainer State Management', () => { // Clear previous calls mocks.mockStdout.write.mockClear(); - let compUnmount: () => void = () => {}; - await act(async () => { - const { unmount } = renderAppContainer(); - compUnmount = unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); // Allow async effects to run - await waitFor(() => expect(capturedUIState).toBeTruthy()); + expect(capturedUIState).toBeTruthy(); // Wait for fetchBannerTexts to complete await act(async () => { @@ -3273,7 +3115,7 @@ describe('AppContainer State Management', () => { ); expect(clearTerminalCalls).toHaveLength(0); - compUnmount(); + unmount(); }); }); @@ -3284,14 +3126,13 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue([]); - let unmount: () => void; - await act(async () => { - unmount = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: createMockSettings({ ui: { useAlternateBuffer: false } }), - }).unmount; - }); + }), + ); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); // Expand first act(() => capturedUIActions.setConstrainHeight(false)); @@ -3309,7 +3150,7 @@ describe('AppContainer State Management', () => { expect(mocks.mockStdout.write).toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); - unmount!(); + unmount(); }); it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { @@ -3320,14 +3161,13 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); - let unmount: () => void; - await act(async () => { - unmount = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }).unmount; - }); + }), + ); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); // Expand first act(() => capturedUIActions.setConstrainHeight(false)); @@ -3345,7 +3185,7 @@ describe('AppContainer State Management', () => { expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); - unmount!(); + unmount(); }); }); @@ -3358,13 +3198,9 @@ describe('AppContainer State Management', () => { vi.useRealTimers(); }); - it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + it('should set showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { + const { unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Trigger overflow act(() => { @@ -3390,16 +3226,12 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); }); it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // 1. Trigger first overflow act(() => { @@ -3447,18 +3279,12 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); }); it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { - let unmount: () => void; - let stdin: ReturnType['stdin']; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - stdin = result.stdin; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { stdin, unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Initial state is constrainHeight = true expect(capturedUIState.constrainHeight).toBe(true); @@ -3483,10 +3309,8 @@ describe('AppContainer State Management', () => { stdin.write('\x0f'); // \x0f is Ctrl+O }); - await waitFor(() => { - // constrainHeight should toggle - expect(capturedUIState.constrainHeight).toBe(false); - }); + // constrainHeight should toggle + expect(capturedUIState.constrainHeight).toBe(false); // Advance enough that the original timer would have expired if it hadn't reset act(() => { @@ -3505,18 +3329,12 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); }); it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { - let unmount: () => void; - let stdin: ReturnType['stdin']; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - stdin = result.stdin; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { stdin, unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Initial state is constrainHeight = true expect(capturedUIState.constrainHeight).toBe(true); @@ -3540,9 +3358,7 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - await waitFor(() => { - expect(capturedUIState.constrainHeight).toBe(false); - }); + expect(capturedUIState.constrainHeight).toBe(false); // Wait 1 second act(() => { @@ -3554,9 +3370,7 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - await waitFor(() => { - expect(capturedUIState.constrainHeight).toBe(true); - }); + expect(capturedUIState.constrainHeight).toBe(true); // Wait 1 second act(() => { @@ -3568,9 +3382,7 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - await waitFor(() => { - expect(capturedUIState.constrainHeight).toBe(false); - }); + expect(capturedUIState.constrainHeight).toBe(false); // Now we wait just before the timeout from the LAST toggle. // It should still be true. @@ -3588,7 +3400,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); }); it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { @@ -3598,14 +3410,12 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: settingsWithAlternateBuffer, - }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + }), + ); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Trigger overflow act(() => { @@ -3617,7 +3427,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(true); }); - unmount!(); + unmount(); }); }); @@ -3628,10 +3438,9 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); - let unmount: () => void; - await act(async () => (unmount = renderAppContainer().unmount)); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); await act(async () => capturedUIActions.handleFinalSubmit('read @file.txt'), @@ -3641,7 +3450,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([ '/test/file.txt', ]); - await act(async () => unmount!()); + unmount(); }); it.each([true, false])( @@ -3657,10 +3466,9 @@ describe('AppContainer State Management', () => { ); const { submitQuery } = mockedUseGeminiStream(); - let unmount: () => void; - await act(async () => (unmount = renderAppContainer().unmount)); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); await act(async () => capturedUIActions.handleFinalSubmit('read @file.txt'), @@ -3679,7 +3487,7 @@ describe('AppContainer State Management', () => { } expect(submitQuery).toHaveBeenCalledWith('read @file.txt'); expect(capturedUIState.permissionConfirmationRequest).toBeNull(); - await act(async () => unmount!()); + unmount(); }, ); }); @@ -3692,17 +3500,11 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(true); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(true); + unmount(); }); it('should NOT allow plan mode when disabled in config', async () => { @@ -3712,17 +3514,11 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); }); it('should NOT allow plan mode when streaming', async () => { @@ -3733,17 +3529,11 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); }); it('should NOT allow plan mode when a tool is awaiting confirmation', async () => { @@ -3764,17 +3554,11 @@ describe('AppContainer State Management', () => { ], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); }); }); }); diff --git a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx index 5df3534f12..d05a17dad8 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx @@ -42,6 +42,7 @@ describe('IdeIntegrationNudge', () => { beforeEach(() => { vi.mocked(debugLogger.warn).mockImplementation((...args) => { if ( + // eslint-disable-next-line no-restricted-syntax typeof args[0] === 'string' && /was not wrapped in act/.test(args[0]) ) { @@ -53,10 +54,9 @@ describe('IdeIntegrationNudge', () => { }); it('renders correctly with default options', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?'); @@ -72,8 +72,6 @@ describe('IdeIntegrationNudge', () => { , ); - await waitUntilReady(); - // "Yes" is the first option and selected by default usually. await act(async () => { stdin.write('\r'); @@ -93,8 +91,6 @@ describe('IdeIntegrationNudge', () => { , ); - await waitUntilReady(); - // Navigate down to "No (esc)" await act(async () => { stdin.write('\u001B[B'); // Down arrow @@ -119,8 +115,6 @@ describe('IdeIntegrationNudge', () => { , ); - await waitUntilReady(); - // Navigate down to "No, don't ask again" await act(async () => { stdin.write('\u001B[B'); // Down arrow @@ -150,8 +144,6 @@ describe('IdeIntegrationNudge', () => { , ); - await waitUntilReady(); - // Press Escape await act(async () => { stdin.write('\u001B'); @@ -178,8 +170,6 @@ describe('IdeIntegrationNudge', () => { , ); - await waitUntilReady(); - const frame = lastFrame(); expect(frame).toContain( diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index b8de6adb0b..d46e0295a1 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -73,23 +73,21 @@ describe('ApiAuthDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders with a defaultValue', async () => { - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); expect(mockedUseTextBuffer).toHaveBeenCalledWith( expect.objectContaining({ initialText: 'test-key', @@ -113,10 +111,9 @@ describe('ApiAuthDialog', () => { 'calls $expectedCall.name when $keyName is pressed', async ({ keyName, sequence, expectedCall, args }) => { mockBuffer.text = 'submitted-key'; // Set for the onSubmit case - const { waitUntilReady, unmount } = render( + const { unmount } = await 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]; @@ -136,24 +133,22 @@ describe('ApiAuthDialog', () => { ); it('displays an error message', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Invalid API Key'); unmount(); }); it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); // Call 0 is ApiAuthDialog (isActive: true) // Call 1 is TextInput (isActive: true, priority: true) const keypressHandler = mockedUseKeypress.mock.calls[0][0]; diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 878b2a8ee0..4837a71490 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -143,10 +143,9 @@ describe('AuthDialog', () => { for (const [key, value] of Object.entries(env)) { vi.stubEnv(key, value as string); } - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; for (const item of shouldContain) { expect(items).toContainEqual(item); @@ -161,10 +160,7 @@ describe('AuthDialog', () => { it('filters auth types when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(1); expect(items[0].value).toBe(AuthType.USE_GEMINI); @@ -173,10 +169,7 @@ describe('AuthDialog', () => { it('sets initial index to 0 when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(initialIndex).toBe(0); unmount(); @@ -213,10 +206,7 @@ describe('AuthDialog', () => { }, ])('selects initial auth type $desc', async ({ setup, expected }) => { setup(); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(items[initialIndex].value).toBe(expected); unmount(); @@ -226,10 +216,7 @@ describe('AuthDialog', () => { describe('handleAuthSelect', () => { it('calls onAuthError if validation fails', async () => { mockedValidateAuthMethod.mockReturnValue('Invalid method'); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; handleAuthSelect(AuthType.USE_GEMINI); @@ -245,10 +232,7 @@ describe('AuthDialog', () => { it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); @@ -261,10 +245,7 @@ describe('AuthDialog', () => { it('sets auth context with empty object for other auth types', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -278,10 +259,7 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -297,10 +275,7 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', ''); // Empty string // props.settings.merged.security.auth.selectedType is undefined here - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -316,10 +291,7 @@ describe('AuthDialog', () => { // process.env['GEMINI_API_KEY'] is not set // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -337,10 +309,7 @@ describe('AuthDialog', () => { props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -360,10 +329,7 @@ describe('AuthDialog', () => { vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true); mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await act(async () => { @@ -383,10 +349,9 @@ describe('AuthDialog', () => { it('displays authError when provided', async () => { props.authError = 'Something went wrong'; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Something went wrong'); unmount(); }); @@ -429,10 +394,7 @@ describe('AuthDialog', () => { }, ])('$desc', async ({ setup, expectations }) => { setup(); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ name: 'escape' }); expectations(props); @@ -442,30 +404,27 @@ describe('AuthDialog', () => { describe('Snapshots', () => { it('renders correctly with default props', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders correctly with auth error', async () => { props.authError = 'Something went wrong'; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders correctly with enforced auth type', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await 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 bd6a3cb126..a387fcb6f3 100644 --- a/packages/cli/src/ui/auth/AuthInProgress.test.tsx +++ b/packages/cli/src/ui/auth/AuthInProgress.test.tsx @@ -42,6 +42,7 @@ describe('AuthInProgress', () => { vi.useFakeTimers(); vi.mocked(debugLogger.error).mockImplementation((...args) => { if ( + // eslint-disable-next-line no-restricted-syntax typeof args[0] === 'string' && args[0].includes('was not wrapped in act') ) { @@ -55,20 +56,18 @@ describe('AuthInProgress', () => { }); it('renders initial state with spinner', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toContain('[Spinner] Waiting for authentication...'); expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel'); unmount(); }); it('calls onTimeout when ESC is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; await act(async () => { @@ -84,10 +83,9 @@ describe('AuthInProgress', () => { }); it('calls onTimeout when Ctrl+C is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; await act(async () => { @@ -100,10 +98,9 @@ describe('AuthInProgress', () => { }); it('calls onTimeout and shows timeout message after 3 minutes', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); await act(async () => { vi.advanceTimersByTime(180000); @@ -116,10 +113,7 @@ describe('AuthInProgress', () => { }); it('clears timer on unmount', async () => { - const { waitUntilReady, unmount } = render( - , - ); - await waitUntilReady(); + const { unmount } = await render(); await act(async () => { unmount(); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx index 0670c81bc9..4b5d44e6d5 100644 --- a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx +++ b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx @@ -73,14 +73,13 @@ describe('BannedAccountDialog', () => { }); it('renders the suspension message from accountSuspensionInfo', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain('Account Suspended'); expect(frame).toContain('violation of Terms of Service'); @@ -89,14 +88,13 @@ describe('BannedAccountDialog', () => { }); it('renders menu options with appeal link text from response', async () => { - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(3); expect(items[0].label).toBe('Appeal Here'); @@ -109,14 +107,13 @@ describe('BannedAccountDialog', () => { const infoWithoutUrl: AccountSuspensionInfo = { message: 'Account suspended.', }; - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(2); expect(items[0].label).toBe('Change authentication'); @@ -129,28 +126,26 @@ describe('BannedAccountDialog', () => { message: 'Account suspended.', appealUrl: 'https://example.com/appeal', }; - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items[0].label).toBe('Open the Google Form'); unmount(); }); it('opens browser when appeal option is selected', async () => { - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await onSelect('open_form'); expect(mockedOpenBrowser).toHaveBeenCalledWith( @@ -162,14 +157,13 @@ describe('BannedAccountDialog', () => { it('shows URL when browser cannot be launched', async () => { mockedShouldLaunchBrowser.mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; onSelect('open_form'); await waitFor(() => { @@ -180,14 +174,13 @@ describe('BannedAccountDialog', () => { }); it('calls onExit when "Exit" is selected', async () => { - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await onSelect('exit'); expect(mockedRunExitCleanup).toHaveBeenCalled(); @@ -196,14 +189,13 @@ describe('BannedAccountDialog', () => { }); it('calls onChangeAuth when "Change authentication" is selected', async () => { - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; onSelect('change_auth'); expect(onChangeAuth).toHaveBeenCalled(); @@ -212,14 +204,13 @@ describe('BannedAccountDialog', () => { }); it('exits on escape key', async () => { - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; const result = keypressHandler({ name: 'escape' }); expect(result).toBe(true); @@ -227,14 +218,13 @@ describe('BannedAccountDialog', () => { }); it('renders snapshot correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx index 77310e3069..4dd13a3334 100644 --- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx @@ -45,25 +45,23 @@ describe('LoginWithGoogleRestartDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('calls onDismiss when escape is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ @@ -83,13 +81,12 @@ describe('LoginWithGoogleRestartDialog', () => { async (keyName) => { vi.useFakeTimers(); - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ diff --git a/packages/cli/src/ui/auth/useAuth.test.tsx b/packages/cli/src/ui/auth/useAuth.test.tsx index f236428ff1..8d51e46a64 100644 --- a/packages/cli/src/ui/auth/useAuth.test.tsx +++ b/packages/cli/src/ui/auth/useAuth.test.tsx @@ -4,15 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type Mock, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js'; import { @@ -22,7 +15,6 @@ import { } from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; -import { waitFor } from '../../test-utils/async.js'; // Mock dependencies const mockLoadApiKey = vi.fn(); @@ -142,171 +134,202 @@ describe('useAuth', () => { }, }) as LoadedSettings; + let deferredRefreshAuth: { + resolve: () => void; + reject: (e: Error) => void; + }; + + beforeEach(() => { + vi.mocked(mockConfig.refreshAuth).mockImplementation( + () => + new Promise((resolve, reject) => { + deferredRefreshAuth = { resolve, reject }; + }), + ); + }); + it('should initialize with Unauthenticated state', async () => { - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); + // Because we defer refreshAuth, the initial state is safely caught here expect(result.current.authState).toBe(AuthState.Unauthenticated); - await waitFor(() => { - expect(result.current.authState).toBe(AuthState.Authenticated); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(result.current.authState).toBe(AuthState.Authenticated); }); it('should set error if no auth type is selected and no env key', async () => { - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(undefined), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toBe( - 'No authentication method selected.', - ); - expect(result.current.authState).toBe(AuthState.Updating); - }); + // This happens synchronously, no deferred promise + expect(result.current.authError).toBe( + 'No authentication method selected.', + ); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should set error if no auth type is selected but env key exists', async () => { process.env['GEMINI_API_KEY'] = 'env-key'; - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(undefined), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toContain( - 'Existing API key detected (GEMINI_API_KEY)', - ); - expect(result.current.authState).toBe(AuthState.Updating); - }); + expect(result.current.authError).toContain( + 'Existing API key detected (GEMINI_API_KEY)', + ); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => { - mockLoadApiKey.mockResolvedValue(null); - const { result } = renderHook(() => + let deferredLoadKey: { resolve: (k: string | null) => void }; + mockLoadApiKey.mockImplementation( + () => + new Promise((resolve) => { + deferredLoadKey = { resolve }; + }), + ); + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput); + await act(async () => { + deferredLoadKey.resolve(null); }); + + expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput); }); it('should authenticate if USE_GEMINI and key is found', async () => { - mockLoadApiKey.mockResolvedValue('stored-key'); - const { result } = renderHook(() => + let deferredLoadKey: { resolve: (k: string | null) => void }; + mockLoadApiKey.mockImplementation( + () => + new Promise((resolve) => { + deferredLoadKey = { resolve }; + }), + ); + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.USE_GEMINI, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.apiKeyDefaultValue).toBe('stored-key'); + await act(async () => { + deferredLoadKey.resolve('stored-key'); }); + + await act(async () => { + deferredRefreshAuth.resolve(); + }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('stored-key'); }); it('should authenticate if USE_GEMINI and env key is found', async () => { - mockLoadApiKey.mockResolvedValue(null); process.env['GEMINI_API_KEY'] = 'env-key'; - const { result } = renderHook(() => + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.USE_GEMINI, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.apiKeyDefaultValue).toBe('env-key'); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); it('should prioritize env key over stored key when both are present', async () => { - mockLoadApiKey.mockResolvedValue('stored-key'); process.env['GEMINI_API_KEY'] = 'env-key'; - const { result } = renderHook(() => + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.USE_GEMINI, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - // The environment key should take precedence - expect(result.current.apiKeyDefaultValue).toBe('env-key'); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); it('should set error if validation fails', async () => { mockValidateAuthMethod.mockReturnValue('Validation Failed'); - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toBe('Validation Failed'); - expect(result.current.authState).toBe(AuthState.Updating); - }); + expect(result.current.authError).toBe('Validation Failed'); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => { process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE'; - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toContain( - 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE', - ); - expect(result.current.authState).toBe(AuthState.Updating); - }); + expect(result.current.authError).toContain( + 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE', + ); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should authenticate successfully for valid auth type', async () => { - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.LOGIN_WITH_GOOGLE, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.authError).toBeNull(); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.authError).toBeNull(); }); it('should handle refreshAuth failure', async () => { - (mockConfig.refreshAuth as Mock).mockRejectedValue( - new Error('Auth Failed'), - ); - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toContain('Failed to sign in'); - expect(result.current.authState).toBe(AuthState.Updating); + await act(async () => { + deferredRefreshAuth.reject(new Error('Auth Failed')); }); + + expect(result.current.authError).toContain('Failed to sign in'); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should handle ProjectIdRequiredError without "Failed to login" prefix', async () => { const projectIdError = new ProjectIdRequiredError(); - (mockConfig.refreshAuth as Mock).mockRejectedValue(projectIdError); - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toBe( - 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', - ); - expect(result.current.authError).not.toContain('Failed to login'); - expect(result.current.authState).toBe(AuthState.Updating); + await act(async () => { + deferredRefreshAuth.reject(projectIdError); }); + + expect(result.current.authError).toBe( + 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', + ); + expect(result.current.authError).not.toContain('Failed to login'); + expect(result.current.authState).toBe(AuthState.Updating); }); }); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index dc49390c7e..8f065438e2 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -710,10 +710,14 @@ describe('extensionsCommand', () => { size: 100, } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'link', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Linking extension from "${packageName}"...`, @@ -733,10 +737,14 @@ describe('extensionsCommand', () => { } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'link', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to link extension from "${packageName}": ${errorMessage}`, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 8e988917e5..aed7595389 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -286,6 +286,11 @@ async function exploreAction( await installAction(context, extension.url, requestConsentOverride); context.ui.removeComponent(); }, + onLink: async (extension, requestConsentOverride) => { + debugLogger.log(`Linking extension: ${extension.extensionName}`); + await linkAction(context, extension.url, requestConsentOverride); + context.ui.removeComponent(); + }, onClose: () => context.ui.removeComponent(), extensionManager, }), @@ -533,7 +538,11 @@ async function installAction( } } -async function linkAction(context: CommandContext, args: string) { +async function linkAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { const extensionLoader = context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { @@ -582,8 +591,11 @@ async function linkAction(context: CommandContext, args: string) { source: sourceFilepath, type: 'link', }; - const extension = - await extensionLoader.installOrUpdateExtension(installMetadata); + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" linked successfully.`, diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index d93d365a3e..f878091a45 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -38,6 +38,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, coreEvents: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread ...actual.coreEvents, emitFeedback: vi.fn(), }, diff --git a/packages/cli/src/ui/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx index 1db36b1f60..9115ca31c1 100644 --- a/packages/cli/src/ui/components/AboutBox.test.tsx +++ b/packages/cli/src/ui/components/AboutBox.test.tsx @@ -25,10 +25,9 @@ describe('AboutBox', () => { }; it('renders with required props', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('About Gemini CLI'); expect(output).toContain('1.0.0'); @@ -46,10 +45,9 @@ describe('AboutBox', () => { ['tier', 'Enterprise', 'Tier'], ])('renders optional prop %s', async (prop, value, label) => { const props = { ...defaultProps, [prop]: value }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain(label); expect(output).toContain(value); @@ -58,10 +56,9 @@ describe('AboutBox', () => { it('renders Auth Method with email when userEmail is provided', async () => { const props = { ...defaultProps, userEmail: 'test@example.com' }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Signed in with Google (test@example.com)'); unmount(); @@ -69,10 +66,9 @@ describe('AboutBox', () => { it('renders Auth Method correctly when not oauth', async () => { const props = { ...defaultProps, selectedAuthType: 'api-key' }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await 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 19db058b87..76a36fe4dc 100644 --- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx @@ -17,15 +17,14 @@ describe('AdminSettingsChangedDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('restarts on "r" key press', async () => { - const { stdin, waitUntilReady } = await renderWithProviders( + const { stdin } = await renderWithProviders( , { uiActions: { @@ -33,7 +32,6 @@ describe('AdminSettingsChangedDialog', () => { }, }, ); - await waitUntilReady(); act(() => { stdin.write('r'); @@ -43,7 +41,7 @@ describe('AdminSettingsChangedDialog', () => { }); it.each(['r', 'R'])('restarts on "%s" key press', async (key) => { - const { stdin, waitUntilReady } = await renderWithProviders( + const { stdin } = await renderWithProviders( , { uiActions: { @@ -51,7 +49,6 @@ describe('AdminSettingsChangedDialog', () => { }, }, ); - 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 a2bfe052bb..2c6ea454db 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -126,7 +126,6 @@ describe('AgentConfigDialog', () => { />, { settings, uiState: { mainAreaWidth: 100 } }, ); - await result.waitUntilReady(); return result; }; diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx index da71895485..571e0d36d3 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -108,7 +108,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with active and pending tool messages', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -118,14 +118,13 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_and_pending'); unmount(); }); it('renders with empty history and no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -135,14 +134,13 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('empty'); unmount(); }); it('renders with history but no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -152,14 +150,13 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_no_pending'); unmount(); }); it('renders with pending items but no history', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -169,7 +166,6 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_pending_no_history'); unmount(); }); @@ -195,7 +191,7 @@ describe('AlternateBufferQuittingDisplay', () => { ], }, ]; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -205,7 +201,6 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Action Required (was prompted):'); expect(output).toContain('confirming_tool'); @@ -220,7 +215,7 @@ describe('AlternateBufferQuittingDisplay', () => { { id: 1, type: 'user', text: 'Hello Gemini' }, { id: 2, type: 'gemini', text: 'Hello User!' }, ]; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -230,7 +225,6 @@ 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 ac824fefe6..758361be0a 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -29,10 +29,9 @@ describe('', () => { createAnsiToken({ text: 'world!' }), ], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame().trim()).toBe('Hello, world!'); unmount(); }); @@ -47,10 +46,9 @@ describe('', () => { { style: { inverse: true }, text: 'Inverse' }, ])('correctly applies style $text', async ({ style, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...style })]]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame().trim()).toBe(text); unmount(); }); @@ -61,10 +59,9 @@ describe('', () => { { color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' }, ])('correctly applies color $text', async ({ color, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...color })]]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame().trim()).toBe(text); unmount(); }); @@ -76,10 +73,9 @@ describe('', () => { [createAnsiToken({ text: 'Third line' })], [createAnsiToken({ text: '' })], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toBeDefined(); const lines = output.split('\n'); @@ -96,10 +92,9 @@ describe('', () => { [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); @@ -115,10 +110,9 @@ describe('', () => { [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); @@ -135,7 +129,7 @@ describe('', () => { [createAnsiToken({ text: 'Line 4' })], ]; // availableTerminalHeight=3, maxLines=2 => show 2 lines - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { width={80} />, ); - await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 2'); expect(output).toContain('Line 3'); @@ -156,10 +149,9 @@ describe('', () => { for (let i = 0; i < 1000; i++) { largeData.push([createAnsiToken({ text: `Line ${i}` })]); } - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await 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 0d7e2b3a7b..8ff4caaacf 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -27,13 +27,12 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); @@ -50,13 +49,12 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('There are capacity issues'); expect(lastFrame()).toMatchSnapshot(); @@ -72,13 +70,12 @@ describe('', () => { }, }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('Banner'); expect(lastFrame()).toMatchSnapshot(); @@ -103,13 +100,12 @@ describe('', () => { }, }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); @@ -129,13 +125,12 @@ describe('', () => { // and interfering with the expected persistentState.set call. persistentStateMock.setData({ tipsShown: 10 }); - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(persistentStateMock.set).toHaveBeenCalledWith( 'defaultBannerShownCount', @@ -159,13 +154,12 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('First line\\nSecond line'); unmount(); @@ -183,13 +177,12 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 5 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('Tips'); expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6); @@ -206,13 +199,12 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 10 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('Tips'); unmount(); @@ -234,7 +226,6 @@ describe('', () => { const session1 = await renderWithProviders(, { uiState, }); - await session1.waitUntilReady(); expect(session1.lastFrame()).toContain('Tips'); expect(persistentStateMock.get('tipsShown')).toBe(10); @@ -245,7 +236,6 @@ describe('', () => { , {}, ); - 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 4386891c7a..1b2decbe16 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -11,56 +11,50 @@ import { ApprovalMode } from '@google/gemini-cli-core'; describe('ApprovalModeIndicator', () => { it('renders correctly for AUTO_EDIT mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for AUTO_EDIT mode with plan enabled', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for PLAN mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for YOLO mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode with plan enabled', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await 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 8ed240389c..864800a061 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -48,7 +48,7 @@ describe('AskUserDialog', () => { ]; it('renders question and options', async () => { - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -397,7 +396,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('hides progress header for single question', async () => { - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('shows keyboard hints', async () => { - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -471,7 +467,6 @@ describe('AskUserDialog', () => { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toContain('Which testing framework?'); writeKey(stdin, '\x1b[C'); // Right arrow @@ -582,7 +577,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -736,7 +730,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -759,7 +752,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -820,7 +812,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - 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 847dcd9a87..c097028a0d 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -145,7 +145,7 @@ describe('', () => { it('renders the output of the active shell', async () => { const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -166,7 +165,7 @@ describe('', () => { it('renders tabs for multiple shells', async () => { const width = 100; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -187,7 +185,7 @@ describe('', () => { it('highlights the focused state', async () => { const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -208,7 +205,7 @@ describe('', () => { it('resizes the PTY on mount and when dimensions change', async () => { const width = 80; - const { rerender, waitUntilReady, unmount } = render( + const { rerender, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, @@ -241,7 +237,6 @@ describe('', () => { /> , ); - await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, @@ -253,7 +248,7 @@ describe('', () => { it('renders the process list when isListOpenProp is true', async () => { const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -274,7 +268,7 @@ describe('', () => { it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => { const width = 80; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( ', () => { , width, ); - await waitUntilReady(); // Simulate down arrow to select the second process (handled by RadioButtonSelect) await act(async () => { simulateKey({ name: 'down' }); }); - await waitUntilReady(); // Simulate Ctrl+L (handled by BackgroundShellDisplay) await act(async () => { simulateKey({ name: 'l', ctrl: true }); }); - await waitUntilReady(); expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid); expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false); @@ -308,7 +299,7 @@ describe('', () => { it('kills the highlighted process when Ctrl+K is pressed in list view', async () => { const width = 80; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( ', () => { , width, ); - await waitUntilReady(); // Initial state: shell1 (active) is highlighted @@ -329,13 +319,11 @@ describe('', () => { await act(async () => { simulateKey({ name: 'down' }); }); - await waitUntilReady(); // Press Ctrl+K await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); - await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid); unmount(); @@ -343,7 +331,7 @@ describe('', () => { it('kills the active process when Ctrl+K is pressed in output view', async () => { const width = 80; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( ', () => { , width, ); - await waitUntilReady(); await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); - await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid); unmount(); @@ -370,7 +356,7 @@ describe('', () => { it('scrolls to active shell when list opens', async () => { // shell2 is active const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -402,7 +387,7 @@ describe('', () => { mockShells.set(exitedShell.pid, exitedShell); const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - 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 442ee0400f..329a560aec 100644 --- a/packages/cli/src/ui/components/Checklist.test.tsx +++ b/packages/cli/src/ui/components/Checklist.test.tsx @@ -18,10 +18,9 @@ describe('', () => { ]; it('renders nothing when list is empty', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); }); @@ -30,15 +29,14 @@ describe('', () => { { status: 'completed', label: 'Task 1' }, { status: 'cancelled', label: 'Task 2' }, ]; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); }); it('renders summary view correctly (collapsed)', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( ', () => { toggleHint="toggle me" />, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders expanded view correctly', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( ', () => { toggleHint="toggle me" />, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -68,10 +64,9 @@ describe('', () => { { status: 'completed', label: 'Task 1' }, { status: 'pending', label: 'Task 2' }, ]; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await 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 4176f7914b..c71af523e1 100644 --- a/packages/cli/src/ui/components/ChecklistItem.test.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.test.tsx @@ -17,8 +17,7 @@ describe('', () => { { status: 'cancelled', label: 'Skipped this' }, { status: 'blocked', label: 'Blocked this' }, ] as ChecklistItemData[])('renders %s item correctly', async (item) => { - const { lastFrame, waitUntilReady } = render(); - await waitUntilReady(); + const { lastFrame } = await render(); expect(lastFrame()).toMatchSnapshot(); }); @@ -28,12 +27,11 @@ describe('', () => { label: 'This is a very long text that should be truncated because the wrap prop is set to truncate', }; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -43,12 +41,11 @@ describe('', () => { label: 'This is a very long text that should wrap because the default behavior is wrapping', }; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await 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 cca997f370..4da6abb199 100644 --- a/packages/cli/src/ui/components/CliSpinner.test.tsx +++ b/packages/cli/src/ui/components/CliSpinner.test.tsx @@ -17,10 +17,7 @@ describe('', () => { it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => { expect(debugState.debugNumAnimatedComponents).toBe(0); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); expect(debugState.debugNumAnimatedComponents).toBe(1); unmount(); expect(debugState.debugNumAnimatedComponents).toBe(0); @@ -28,11 +25,9 @@ describe('', () => { it('should not render when showSpinner is false', async () => { const settings = createMockSettings({ ui: { showSpinner: false } }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { settings }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + settings, + }); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); diff --git a/packages/cli/src/ui/components/ColorsDisplay.test.tsx b/packages/cli/src/ui/components/ColorsDisplay.test.tsx index fdd08fd653..d934831c0e 100644 --- a/packages/cli/src/ui/components/ColorsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ColorsDisplay.test.tsx @@ -96,10 +96,9 @@ describe('ColorsDisplay', () => { it('renders correctly', async () => { const mockTheme = themeManager.getActiveTheme(); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); // Check for title and description diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 641fc24810..8df5f690e7 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -251,7 +251,7 @@ const renderComposer = async ( config = createMockConfig(), uiActions = createMockUIActions(), ) => { - const result = render( + const result = await render( @@ -262,7 +262,6 @@ const renderComposer = async ( , ); - await result.waitUntilReady(); // Wait for shortcuts hint debounce if using fake timers if (vi.isFakeTimers()) { diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx index 45ead4862e..b4ae8b93b1 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx @@ -43,10 +43,7 @@ describe('ConfigInitDisplay', () => { }); it('renders initial state', async () => { - const { lastFrame, waitUntilReady } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { lastFrame } = await renderWithProviders(); expect(lastFrame()).toMatchSnapshot(); }); diff --git a/packages/cli/src/ui/components/ConsentPrompt.test.tsx b/packages/cli/src/ui/components/ConsentPrompt.test.tsx index dd69c44dd5..09a2dde16e 100644 --- a/packages/cli/src/ui/components/ConsentPrompt.test.tsx +++ b/packages/cli/src/ui/components/ConsentPrompt.test.tsx @@ -33,14 +33,13 @@ describe('ConsentPrompt', () => { it('renders a string prompt with MarkdownDisplay', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); expect(MockedMarkdownDisplay).toHaveBeenCalledWith( { @@ -55,14 +54,13 @@ describe('ConsentPrompt', () => { it('renders a ReactNode prompt directly', async () => { const prompt = Are you sure?; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(MockedMarkdownDisplay).not.toHaveBeenCalled(); expect(lastFrame()).toContain('Are you sure?'); @@ -71,14 +69,13 @@ describe('ConsentPrompt', () => { it('calls onConfirm with true when "Yes" is selected', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; await act(async () => { @@ -92,14 +89,13 @@ describe('ConsentPrompt', () => { it('calls onConfirm with false when "No" is selected', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; await act(async () => { @@ -113,14 +109,13 @@ describe('ConsentPrompt', () => { it('passes correct items to RadioButtonSelect', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { unmount } = await 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 cb8db1a895..b7662c3a26 100644 --- a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx @@ -10,10 +10,9 @@ import { describe, it, expect } from 'vitest'; describe('ConsoleSummaryDisplay', () => { it('renders nothing when errorCount is 0', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -22,10 +21,9 @@ describe('ConsoleSummaryDisplay', () => { [1, '1 error'], [5, '5 errors'], ])('renders correct message for %i errors', async (count, expectedText) => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain(expectedText); expect(output).toContain('✖'); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx index f48cfb2a31..1049e97912 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -26,8 +26,7 @@ const renderWithWidth = async ( props: React.ComponentProps, ) => { useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); - const result = render(); - await result.waitUntilReady(); + const result = await render(); return result; }; diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index 904e06635c..d8ec1650ee 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -19,35 +19,33 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { describe('ContextUsageDisplay', () => { it('renders correct percentage used', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('50% used'); unmount(); }); it('renders correctly when usage is 0%', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('0% used'); unmount(); }); it('renders abbreviated label when terminal width is small', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { />, { width: 80 }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('20%'); expect(output).not.toContain('context used'); @@ -63,28 +60,26 @@ describe('ContextUsageDisplay', () => { }); it('renders 80% correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('80% used'); unmount(); }); it('renders 100% when full', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('100% used'); unmount(); diff --git a/packages/cli/src/ui/components/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx index 6f202ced4a..cc20a142dd 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.test.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx @@ -22,8 +22,7 @@ describe('CopyModeWarning', () => { mockUseUIState.mockReturnValue({ copyModeEnabled: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -32,8 +31,7 @@ describe('CopyModeWarning', () => { mockUseUIState.mockReturnValue({ copyModeEnabled: true, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame()).toContain('In Copy Mode'); expect(lastFrame()).toContain('Use Page Up/Down to scroll'); expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit'); diff --git a/packages/cli/src/ui/components/DebugProfiler.test.tsx b/packages/cli/src/ui/components/DebugProfiler.test.tsx index d4c0e28902..a014c740f0 100644 --- a/packages/cli/src/ui/components/DebugProfiler.test.tsx +++ b/packages/cli/src/ui/components/DebugProfiler.test.tsx @@ -242,8 +242,7 @@ describe('DebugProfiler Component', () => { showDebugProfiler: false, constrainHeight: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -257,8 +256,7 @@ describe('DebugProfiler Component', () => { profiler.totalIdleFrames = 5; profiler.totalFlickerFrames = 2; - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); const output = lastFrame(); expect(output).toContain('Renders: 10 (total)'); @@ -275,8 +273,7 @@ describe('DebugProfiler Component', () => { const reportActionSpy = vi.spyOn(profiler, 'reportAction'); - const { waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { waitUntilReady, unmount } = await render(); await act(async () => { coreEvents.emitModelChanged('new-model'); @@ -295,8 +292,7 @@ describe('DebugProfiler Component', () => { const reportActionSpy = vi.spyOn(profiler, 'reportAction'); - const { waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { waitUntilReady, unmount } = await render(); await act(async () => { appEvents.emit(AppEvent.SelectionWarning); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index b2f4185842..30f98a6eda 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -41,13 +41,12 @@ describe('DetailedMessagesDisplay', () => { }); }); it('renders nothing when messages are empty', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -64,13 +63,12 @@ describe('DetailedMessagesDisplay', () => { clearConsoleMessages: vi.fn(), }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); @@ -86,13 +84,12 @@ describe('DetailedMessagesDisplay', () => { clearConsoleMessages: vi.fn(), }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { settings: createMockSettings({ ui: { errorVerbosity: 'low' } }), }, ); - await waitUntilReady(); expect(lastFrame()).toContain('(F12 to close)'); unmount(); }); @@ -106,13 +103,12 @@ describe('DetailedMessagesDisplay', () => { clearConsoleMessages: vi.fn(), }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); - await waitUntilReady(); expect(lastFrame()).toContain('(F12 to close)'); unmount(); }); @@ -126,13 +122,12 @@ describe('DetailedMessagesDisplay', () => { clearConsoleMessages: vi.fn(), }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index 6f6dbb0289..31b28f5223 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -104,11 +104,10 @@ describe('DialogManager', () => { }; it('renders nothing by default', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: baseUiState as Partial as UIState }, ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -197,7 +196,7 @@ describe('DialogManager', () => { it.each(testCases)( 'renders %s when state is %o', async (uiStateOverride, expectedComponent) => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -206,7 +205,6 @@ 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 bd995652b1..18b47def7b 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -55,27 +55,25 @@ describe('EditorSettingsDialog', () => { renderWithProviders(ui); it('renders correctly', async () => { - const { lastFrame, waitUntilReady } = await renderWithProvider( + const { lastFrame } = await renderWithProvider( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('calls onSelect when an editor is selected', async () => { const onSelect = vi.fn(); - const { lastFrame, waitUntilReady } = await renderWithProvider( + const { lastFrame } = await renderWithProvider( , ); - await waitUntilReady(); expect(lastFrame()).toContain('VS Code'); }); @@ -88,7 +86,6 @@ describe('EditorSettingsDialog', () => { onExit={vi.fn()} />, ); - await waitUntilReady(); // Initial focus on editor expect(lastFrame()).toContain('> Select Editor'); @@ -134,7 +131,6 @@ describe('EditorSettingsDialog', () => { onExit={onExit} />, ); - await waitUntilReady(); await act(async () => { stdin.write('\u001B'); // Escape @@ -162,14 +158,13 @@ describe('EditorSettingsDialog', () => { }, } as unknown as LoadedSettings; - const { lastFrame, waitUntilReady } = await renderWithProvider( + const { lastFrame } = await renderWithProvider( , ); - await waitUntilReady(); const frame = lastFrame() || ''; if (!frame.includes('(Also modified')) { diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx index 23a2038b10..74de1a8a41 100644 --- a/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx +++ b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx @@ -30,7 +30,7 @@ describe('EmptyWalletDialog', () => { describe('rendering', () => { it('should match snapshot with fallback available', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { onChoice={mockOnChoice} />, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('should match snapshot without fallback', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('should display the model name and usage limit message', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('gemini-2.5-pro'); @@ -73,13 +70,12 @@ describe('EmptyWalletDialog', () => { }); it('should display purchase prompt and credits update notice', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('purchase more AI Credits'); @@ -90,14 +86,13 @@ describe('EmptyWalletDialog', () => { }); it('should display reset time when provided', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('3:45 PM'); @@ -106,13 +101,12 @@ describe('EmptyWalletDialog', () => { }); it('should not display reset time when not provided', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).not.toContain('Access resets at'); @@ -120,13 +114,12 @@ describe('EmptyWalletDialog', () => { }); it('should display slash command hints', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('/stats'); @@ -139,14 +132,13 @@ describe('EmptyWalletDialog', () => { describe('onChoice handling', () => { it('should call onGetCredits and onChoice when get_credits is selected', async () => { // get_credits is the first item, so just press Enter - const { unmount, stdin, waitUntilReady } = await renderWithProviders( + const { unmount, stdin } = await renderWithProviders( , ); - await waitUntilReady(); writeKey(stdin, '\r'); @@ -158,13 +150,12 @@ describe('EmptyWalletDialog', () => { }); it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => { - const { unmount, stdin, waitUntilReady } = await renderWithProviders( + const { unmount, stdin } = await renderWithProviders( , ); - await waitUntilReady(); writeKey(stdin, '\r'); @@ -177,14 +168,13 @@ describe('EmptyWalletDialog', () => { it('should call onChoice with use_fallback when selected', async () => { // With fallback: items are [get_credits, use_fallback, stop] // use_fallback is the second item: Down + Enter - const { unmount, stdin, waitUntilReady } = await renderWithProviders( + const { unmount, stdin } = await renderWithProviders( , ); - await waitUntilReady(); writeKey(stdin, '\x1b[B'); // Down arrow writeKey(stdin, '\r'); @@ -198,13 +188,12 @@ describe('EmptyWalletDialog', () => { it('should call onChoice with stop when selected', async () => { // Without fallback: items are [get_credits, stop] // stop is the second item: Down + Enter - const { unmount, stdin, waitUntilReady } = await renderWithProviders( + const { unmount, stdin } = await renderWithProviders( , ); - await waitUntilReady(); writeKey(stdin, '\x1b[B'); // Down arrow writeKey(stdin, '\r'); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index f369e7ff8e..d6fc23dd70 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -440,36 +440,38 @@ Implement a comprehensive authentication system with multiple providers. return <>{children}; }; - const { stdin, lastFrame } = await renderWithProviders( - - - , - { - config: { - getTargetDir: () => mockTargetDir, - getIdeMode: () => false, - isTrustedFolder: () => true, - storage: { - getPlansDir: () => mockPlansDir, - }, - getFileSystemService: (): FileSystemService => ({ - readTextFile: vi.fn(), - writeTextFile: vi.fn(), + const { stdin, lastFrame } = await act(async () => + renderWithProviders( + + + , + { + config: { + getTargetDir: () => mockTargetDir, + getIdeMode: () => false, + isTrustedFolder: () => true, + storage: { + getPlansDir: () => mockPlansDir, + }, + getFileSystemService: (): FileSystemService => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + }), + getUseAlternateBuffer: () => useAlternateBuffer ?? true, + } as unknown as import('@google/gemini-cli-core').Config, + settings: createMockSettings({ + ui: { useAlternateBuffer: useAlternateBuffer ?? true }, }), - getUseAlternateBuffer: () => useAlternateBuffer ?? true, - } as unknown as import('@google/gemini-cli-core').Config, - settings: createMockSettings({ - ui: { useAlternateBuffer: useAlternateBuffer ?? true }, - }), - }, + }, + ), ); await act(async () => { diff --git a/packages/cli/src/ui/components/ExitWarning.test.tsx b/packages/cli/src/ui/components/ExitWarning.test.tsx index 6d495a5e21..a504670d03 100644 --- a/packages/cli/src/ui/components/ExitWarning.test.tsx +++ b/packages/cli/src/ui/components/ExitWarning.test.tsx @@ -24,8 +24,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: false, ctrlDPressedOnce: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -36,8 +35,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: true, ctrlDPressedOnce: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame()).toContain('Press Ctrl+C again to exit'); unmount(); }); @@ -48,8 +46,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: false, ctrlDPressedOnce: true, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame()).toContain('Press Ctrl+D again to exit'); unmount(); }); @@ -60,8 +57,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: true, ctrlDPressedOnce: true, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); 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 c1d04b3ff9..de6e8096ec 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -48,10 +48,9 @@ describe('FolderTrustDialog', () => { }); it('should render the dialog with title and description', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Do you trust the files in this folder?'); expect(lastFrame()).toContain( @@ -72,7 +71,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { }, ); - await waitUntilReady(); expect(lastFrame()).toContain('This folder contains:'); expect(lastFrame()).toContain('hidden'); unmount(); @@ -103,7 +101,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { }, ); - await waitUntilReady(); // With maxHeight=4, the intro text (4 lines) will take most of the space. // The discovery results will likely be hidden. expect(lastFrame()).toContain('hidden'); @@ -135,7 +132,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { }, ); - await waitUntilReady(); expect(lastFrame()).toContain('hidden'); unmount(); }); @@ -182,9 +178,7 @@ describe('FolderTrustDialog', () => { // Initial state: truncated await waitFor(() => { expect(lastFrame()).toContain('Do you trust the files in this folder?'); - // In standard terminal mode, the expansion hint is handled globally by ToastDisplay - // via AppContainer, so it should not be present in the dialog's local frame. - expect(lastFrame()).not.toContain('Press Ctrl+O'); + expect(lastFrame()).toContain('Press Ctrl+O'); expect(lastFrame()).toContain('hidden'); }); @@ -221,7 +215,6 @@ describe('FolderTrustDialog', () => { await renderWithProviders( , ); - await waitUntilReady(); await act(async () => { stdin.write('\u001b[27u'); // Press kitty escape key @@ -246,10 +239,9 @@ describe('FolderTrustDialog', () => { }); it('should display restart message when isRestarting is true', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Gemini CLI is restarting'); unmount(); @@ -260,10 +252,9 @@ describe('FolderTrustDialog', () => { const relaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); await vi.advanceTimersByTimeAsync(250); expect(relaunchApp).toHaveBeenCalled(); unmount(); @@ -275,10 +266,9 @@ describe('FolderTrustDialog', () => { const relaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); // Unmount immediately (before 250ms) unmount(); @@ -292,7 +282,6 @@ describe('FolderTrustDialog', () => { const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); - await waitUntilReady(); await act(async () => { stdin.write('r'); @@ -308,30 +297,27 @@ describe('FolderTrustDialog', () => { describe('directory display', () => { it('should correctly display the folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Trust folder (project)'); unmount(); }); it('should correctly display the parent folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await 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', async () => { mockedCwd.mockReturnValue('/project'); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Trust parent folder ()'); unmount(); }); @@ -348,7 +334,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { { width: 80 }, ); - await waitUntilReady(); expect(lastFrame()).toContain('This folder contains:'); expect(lastFrame()).toContain('• Commands (2):'); expect(lastFrame()).toContain('- cmd1'); @@ -386,14 +371,13 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: ['Dangerous setting detected!'], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Security Warnings:'); expect(lastFrame()).toContain('Dangerous setting detected!'); unmount(); @@ -410,14 +394,13 @@ describe('FolderTrustDialog', () => { discoveryErrors: ['Failed to load custom commands'], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Discovery Errors:'); expect(lastFrame()).toContain('Failed to load custom commands'); unmount(); @@ -434,7 +417,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { }, ); - await waitUntilReady(); // In alternate buffer + expanded, the title should be visible (StickyHeader) expect(lastFrame()).toContain('Do you trust the files in this folder?'); // And it should NOT use MaxSizedBox truncation @@ -470,7 +452,7 @@ describe('FolderTrustDialog', () => { securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { { width: 100, uiState: { terminalHeight: 40 } }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('cmd-with-ansi'); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 39f20e1c86..c0a52af868 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -138,33 +138,25 @@ describe('