Compare commits

..

69 Commits

Author SHA1 Message Date
Cynthia Long 0ea49d486b tempt to fix browser bundle 2026-03-23 22:43:22 +00:00
Cynthia Long 4d4fc80f25 remove unncessary test change 2026-03-23 19:03:41 +00:00
cynthialong0-0 dfa56c37b4 Merge branch 'main' into fix/headless-log 2026-03-23 11:24:41 -07:00
Keith Guerin 447a854ad9 feat(cli): implement full "GEMINI CLI" logo for logged-out state (#22412) 2026-03-23 18:05:00 +00:00
matt korwel b58d79c517 fix(cli): prevent terminal escape sequences from leaking on exit (#22682)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-23 18:01:12 +00:00
Tommaso Sciortino daf3691841 fix: "allow always" for commands with paths (#23558) 2026-03-23 17:25:31 +00:00
Sehoon Shon 517961b2eb perf(cli): parallelize and background startup cleanup tasks (#23545) 2026-03-23 16:26:56 +00:00
Adam Weidman ec0161ad37 feat(core): add event-translator and update agent types (#22985) 2026-03-23 16:08:09 +00:00
Emily Hedlund cdf077da56 feat(core): refactor SandboxManager to a stateless architecture and introduce explicit Deny interface (#23141) 2026-03-23 15:43:58 +00:00
Alisa 99e5164c82 Evals: PR Guidance adding workflow (#23164) 2026-03-23 15:07:40 +00:00
Abhi d0ebc81c28 test(cli): force generic terminal in tests to fix snapshot failures (#23499) 2026-03-23 14:50:25 +00:00
Jack Wotherspoon c67817f1a9 fix(cli): resolve flicker at boundaries of list in BaseSelectionList (#23298) 2026-03-23 03:10:47 +00:00
Abhi c7d44e339b fix(core): ensure subagent tool updates apply configuration overrides immediately (#23161) 2026-03-23 00:24:24 +00:00
N. Taylor Mullen 6055c47079 fix(cli): record skill activation tool calls in chat history (#23203) 2026-03-22 20:36:18 +00:00
Adib234 4c533b1249 fix(plan): add state transition override to prevent plan mode freeze (#23020)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-21 22:56:31 +00:00
N. Taylor Mullen 4a3d9414ef fix(core): enable global session and persistent approval for web_fetch (#23295)
Co-authored-by: Spencer <spencertang@google.com>
2026-03-21 10:32:07 -07:00
Abhi 0df9498674 fix(core): refine CliHelpAgent description for better delegation (#23310) 2026-03-21 06:24:37 +00:00
Christian Gunderman 8f391585ab Disallow and suppress misused spread operator. (#23294) 2026-03-21 05:21:53 +00:00
Abhi e7b6326cfa fix(cli): add Esc instruction to HooksDialog footer (#23258) 2026-03-21 04:29:09 +00:00
Alisa 1a70fdd364 chore(lint): enforce zero warnings and cleanup syntax restrictions (#22902) 2026-03-21 04:01:48 +00:00
cynthialong0-0 b316fcc44d feat(browser): add warning message for session mode 'existing' (#23288) 2026-03-21 03:59:16 +00:00
Christian Gunderman d1dc4902fd fix(hooks): support 'ask' decision for BeforeTool hooks (#21146) 2026-03-21 03:52:39 +00:00
Sandy Tao d3766875f8 fix(evals): remove tool restrictions and add compile-time guards (#23312) 2026-03-21 03:45:33 +00:00
Christian Gunderman 28935d1e6b Retry evals on API error. (#23322) 2026-03-21 02:52:19 +00:00
Yuna Seol 244a608186 feat(core): set up onboarding telemetry (#23118)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-03-21 01:15:47 +00:00
Emily Hedlund fc03891a11 fix(extensions): revert broken extension removal behavior (#23317) 2026-03-20 23:36:52 +00:00
CogitationOps 974d29128f Wrap the text under TOML rule for easier readability in policy-engine.md… (#23076)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-20 23:30:09 +00:00
Abhi 3382e0413e fix(core): fix agent description indentation (#23315) 2026-03-20 23:24:57 +00:00
Indrapal Singh 992c04e768 fix broken extension link in hooks guide (#21728)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-20 23:17:32 +00:00
Alisa fbb17ebf58 Disabling failing test while investigating (#23311) 2026-03-20 22:52:35 +00:00
cynthialong0-0 e8fe43bd69 feat(browser): add sensitive action controls and read-only noise reduction (#22867) 2026-03-20 22:34:04 +00:00
Tommaso Sciortino 11ec4ac2f8 test(cli): address unresolved feedback from PR #23252 (#23303) 2026-03-20 21:42:01 +00:00
Coco Sheng 8eb419a47a refactor(ide): replace dynamic undici import with static fetch import (#23268) 2026-03-20 21:14:25 +00:00
cynthialong0-0 4e80f01fda feat(core): add security prompt for browser agent (#23241) 2026-03-20 20:12:24 +00:00
Tommaso Sciortino 6c78eb7a39 test(cli): refactor tests for async render utilities (#23252) 2026-03-20 20:08:29 +00:00
kevinjwang1 86a3a913b5 Fix schema for ModelChains (#23284) 2026-03-20 19:50:15 +00:00
Sehoon Shon 05e4ea80ee feat(core): refine User-Agent for VS Code traffic (unified format) (#23256) 2026-03-20 19:31:01 +00:00
Dev Randalpura cca595971d fix(core): add sanitization to sub agent thoughts and centralize utilities (#22828) 2026-03-20 19:19:18 +00:00
Sandy Tao 26b9af1cdc refactor(core): use absolute paths in GEMINI.md context markers (#23135) 2026-03-20 19:10:01 +00:00
Jerop Kipruto b459e1a108 feat(telemetry): track if session is running in a Git worktree (#23265) 2026-03-20 19:01:12 +00:00
Ratish P 62cb14fa52 feat(extensions): add --skip-settings flag to install command (#17212) 2026-03-20 16:10:59 +00:00
kevinjwang1 7a65c1e91d Add support for linking in the extension registry (#23153) 2026-03-20 15:08:34 +00:00
Jerop Kipruto 5a3c7154df feat(worktree): add Git worktree support for isolated parallel sessions (#22973) 2026-03-20 14:10:51 +00:00
Michael Bleigh b9c87c14a2 feat(core): introduce AgentSession and rename stream events to agent events (#23159) 2026-03-20 13:40:10 +00:00
nmcnamara-eng 52250c162d feat(cli): conditionally exclude ask_user tool in ACP mode (#23045)
Co-authored-by: Sri Pasumarthi <sripas@google.com>
Co-authored-by: Sri Pasumarthi <111310667+sripasg@users.noreply.github.com>
2026-03-20 06:18:55 +00:00
matt korwel b52641de0d chore(lint): ignore .gemini directory and recursive node_modules (#23211) 2026-03-20 05:57:59 +00:00
cynthialong0-0 4249de5e23 Merge branch 'main' into fix/headless-log 2026-03-18 08:02:11 -07:00
cynthialong0-0 536bbdf73f Merge branch 'main' into fix/headless-log 2026-03-18 07:45:02 -07:00
Cynthia Long 201efbd07f update comment 2026-03-18 14:44:07 +00:00
Cynthia Long 8ae078b4d7 update comment 2026-03-18 14:43:25 +00:00
Cynthia Long d84b173330 rename headlessmode to interactive, add comments 2026-03-18 14:37:45 +00:00
cynthialong0-0 33d654e7d9 Merge branch 'main' into fix/headless-log 2026-03-17 16:08:29 -07:00
cynthialong0-0 6eb100cc41 Merge branch 'main' into fix/headless-log 2026-03-17 15:31:47 -07:00
Cynthia Long dca1ef09cb add debug to extensions-reload install command 2026-03-17 22:30:50 +00:00
cynthialong0-0 616c944350 Merge branch 'main' into fix/headless-log 2026-03-17 12:47:44 -07:00
Cynthia Long c0f919310e add -debug since it treat as noninteractive in e2e 2026-03-17 16:30:34 +00:00
Cynthia Long 92a09c8d8d Update tests 2026-03-17 16:29:45 +00:00
Cynthia Long ecae62e25a fix unit test 2026-03-17 14:32:04 +00:00
Cynthia Long abb4583554 introduce headless param 2026-03-17 14:19:45 +00:00
cynthialong0-0 fc7a269f0a Merge branch 'main' into fix/headless-log 2026-03-17 07:19:32 -07:00
cynthialong0-0 9c85ef1fda Merge branch 'main' into fix/headless-log 2026-03-16 21:55:25 -07:00
Cynthia Long bd60ba9e68 refactor the fix based on the e2e test failure 2026-03-17 04:43:10 +00:00
Cynthia Long 2e466bd4cb fix(cli): conditionally suppress console output in headless and non-interactive modes 2026-03-16 23:36:20 +00:00
Cynthia Long 55778583b9 update test 2026-03-16 22:05:01 +00:00
cynthialong0-0 661bd39362 Merge branch 'main' into fix/headless-log 2026-03-16 14:55:07 -07:00
Cynthia Long 04eb2ee9a3 test(cli): fix type errors and lint in console patcher tests 2026-03-16 21:45:11 +00:00
Cynthia Long c6f6fb00c9 add unit test for console patcher & noninteractive 2026-03-16 21:30:16 +00:00
Cynthia Long 02eff4efb3 set true/false based on if headless mode 2026-03-16 21:06:52 +00:00
Cynthia Long db9eef50ef add suppressConsoleOutput 2026-03-16 18:22:10 +00:00
412 changed files with 11955 additions and 6772 deletions
+10
View File
@@ -7,5 +7,15 @@
},
"general": {
"devtools": true
},
"agents": {
"overrides": {
"browser_agent": { "enabled": true }
},
"browser": {
"headless": true,
"sessionMode": "isolated",
"allowedDomains": ["*.com"]
}
}
}
+69
View File
@@ -0,0 +1,69 @@
name: 'Evals: PR Guidance'
on:
pull_request:
paths:
- 'packages/core/src/**/*.ts'
- '!**/*.test.ts'
- '!**/*.test.tsx'
permissions:
pull-requests: 'write'
contents: 'read'
jobs:
provide-guidance:
name: 'Model Steering Guidance'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
with:
fetch-depth: 0
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Detect Steering Changes'
id: 'detect'
run: |
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Analyze PR Content'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
id: 'analysis'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
# Check for behavioral eval changes
EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true)
if [ -z "$EVAL_CHANGES" ]; then
echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT"
fi
# Check if user is a maintainer (has write/admin access)
USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission')
if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then
echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT"
fi
- name: 'Post Guidance Comment'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
with:
comment-tag: 'eval-guidance-bot'
message: |
### 🧠 Model Steering Guidance
This PR modifies files that affect the model's behavior (prompts, tools, or instructions).
${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- ⚠️ **Consider adding Evals:** No behavioral evaluations (`evals/*.eval.ts`) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.' || '' }}
${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- 🚀 **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }}
---
*This is an automated guidance message triggered by steering logic signatures.*
+1
View File
@@ -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}"
+1
View File
@@ -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. |
+107
View File
@@ -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).
<!-- prettier-ignore -->
> [!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
```
<!-- prettier-ignore -->
> [!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 <session_id>
```
## 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
+6
View File
@@ -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
+8
View File
@@ -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` |
+29
View File
@@ -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.
<details>
<summary>Attributes</summary>
- `phases` (json array of startup phases)
- `os_platform` (string)
- `os_release` (string)
- `is_docker` (boolean)
</details>
</details>
### 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.
<details>
<summary>Attributes (Success)</summary>
- `user_tier` (string)
##### Tools
##### `gemini_cli.tool.call.count`
+2 -1
View File
@@ -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 <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent] [--skip-settings]
```
- `<source>`: The GitHub URL or local path of the extension.
@@ -31,6 +31,7 @@ gemini extensions install <source> [--ref <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
+2 -2
View File
@@ -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.
+116 -50
View File
@@ -1,14 +1,8 @@
# Welcome to Gemini CLI
# Gemini CLI documentation
Unleash the power of Gemini models directly in your terminal. Gemini CLI is your
intelligent assistant for coding, automation, and understanding complex
projects. It seamlessly integrates with your local development environment,
empowering you to accelerate workflows, tackle challenging tasks, and explore
codebases with unprecedented ease.
Whether you're refactoring code, debugging issues, or orchestrating complex
automation, Gemini CLI works alongside you, transforming your terminal into a
highly productive AI-powered workspace.
Gemini CLI brings the power of Gemini models directly into your terminal. Use it
to understand code, automate tasks, and build workflows with your local project
context.
## Install
@@ -16,60 +10,132 @@ highly productive AI-powered workspace.
npm install -g @google/gemini-cli
```
## Get Started in Minutes
## Get started
Ready to boost your productivity? You can get Gemini CLI up and running right
away.
Jump in to Gemini CLI.
→ Dive into the [Quickstart Guide](./get-started/index.md) for your first
interactive session.
- **[Quickstart](./get-started/index.md):** Your first session with Gemini CLI.
- **[Installation](./get-started/installation.md):** How to install Gemini CLI
on your system.
- **[Authentication](./get-started/authentication.md):** Setup instructions for
personal and enterprise accounts.
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
action.
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
commands and options.
- **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3
support in Gemini CLI.
## What Can Gemini CLI Do For You?
## Use Gemini CLI
Gemini CLI isn't just a chatbot; it's a powerful agent capable of executing a
wide range of tasks directly within your project context.
User-focused guides and tutorials for daily development workflows.
### Automate Code Refactoring and Generation
- **[File management](./cli/tutorials/file-management.md):** How to work with
local files and directories.
- **[Get started with Agent skills](./cli/tutorials/skills-getting-started.md):**
Getting started with specialized expertise.
- **[Manage context and memory](./cli/tutorials/memory-management.md):**
Managing persistent instructions and facts.
- **[Execute shell commands](./cli/tutorials/shell-commands.md):** Executing
system commands safely.
- **[Manage sessions and history](./cli/tutorials/session-management.md):**
Resuming, managing, and rewinding conversations.
- **[Plan tasks with todos](./cli/tutorials/task-planning.md):** Using todos for
complex workflows.
- **[Web search and fetch](./cli/tutorials/web-tools.md):** Searching and
fetching content from the web.
- **[Set up an MCP server](./cli/tutorials/mcp-setup.md):** Set up an MCP
server.
- **[Automate tasks](./cli/tutorials/automation.md):** Automate tasks.
Tired of repetitive coding tasks? Let Gemini CLI handle them. It can understand
your existing code to generate new features or refactor complex sections with
ease.
## Features
→ Learn how to efficiently [Modify Code](./cli/tutorials/file-management.md).
Technical documentation for each capability of Gemini CLI.
### Understand Complex Codebases
- **[Extensions](./extensions/index.md):** Extend Gemini CLI with new tools and
capabilities.
- **[Agent Skills](./cli/skills.md):** Use specialized agents for specific
tasks.
- **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots.
- **[Headless mode](./cli/headless.md):** Programmatic and scripting interface.
- **[Hooks](./hooks/index.md):** Customize Gemini CLI behavior with scripts.
- **[IDE integration](./ide-integration/index.md):** Integrate Gemini CLI with
your favorite IDE.
- **[MCP servers](./tools/mcp-server.md):** Connect to and use remote agents.
- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
- **[Model selection](./cli/model.md):** Choose the best model for your needs.
- **[Plan mode 🔬](./cli/plan-mode.md):** Use a safe, read-only mode for
planning complex changes.
- **[Subagents 🔬](./core/subagents.md):** Using specialized agents for specific
tasks.
- **[Remote subagents 🔬](./core/remote-agents.md):** Connecting to and using
remote agents.
- **[Rewind](./cli/rewind.md):** Rewind and replay sessions.
- **[Sandboxing](./cli/sandbox.md):** Isolate tool execution.
- **[Settings](./cli/settings.md):** Full configuration reference.
- **[Telemetry](./cli/telemetry.md):** Usage and performance metric details.
- **[Token caching](./cli/token-caching.md):** Performance optimization.
Navigate unfamiliar projects or quickly grasp new architectural patterns. Gemini
CLI can analyze large code repositories, explaining their purpose, structure,
and key functionalities.
## Configuration
→ Explore examples of how to
[Explain a Repository by Reading its Code](./get-started/examples.md).
Settings and customization options for Gemini CLI.
### Streamline Your Development Workflows
- **[Custom commands](./cli/custom-commands.md):** Personalized shortcuts.
- **[Enterprise configuration](./cli/enterprise.md):** Professional environment
controls.
- **[Ignore files (.geminiignore)](./cli/gemini-ignore.md):** Exclusion pattern
reference.
- **[Model configuration](./cli/generation-settings.md):** Fine-tune generation
parameters like temperature and thinking budget.
- **[Project context (GEMINI.md)](./cli/gemini-md.md):** Technical hierarchy of
context files.
- **[System prompt override](./cli/system-prompt.md):** Instruction replacement
logic.
- **[Themes](./cli/themes.md):** UI personalization technical guide.
- **[Trusted folders](./cli/trusted-folders.md):** Security permission logic.
From planning tasks with TODOs to managing long-running sessions, Gemini CLI
helps you orchestrate your entire development process, making complex workflows
simpler and more efficient.
## Reference
→ Discover how to [Plan Tasks with ToDos](./cli/tutorials/task-planning.md) or
[Manage Sessions and History](./cli/tutorials/session-management.md).
Deep technical documentation and API specifications.
## Explore Key Features and Sections
- **[Command reference](./reference/commands.md):** Detailed slash command
guide.
- **[Configuration reference](./reference/configuration.md):** Settings and
environment variables.
- **[Keyboard shortcuts](./reference/keyboard-shortcuts.md):** Productivity
tips.
- **[Memory import processor](./reference/memport.md):** How Gemini CLI
processes memory from various sources.
- **[Policy engine](./reference/policy-engine.md):** Fine-grained execution
control.
- **[Tools reference](./reference/tools.md):** Information on how tools are
defined, registered, and used.
Dive deeper into Gemini CLI's capabilities and comprehensive documentation:
## Resources
- **[Agent Skills](./cli/skills.md):** Extend Gemini CLI's intelligence with
specialized expertise for any domain or task.
- **[Extensions](./extensions/index.md):** Customize and enhance your CLI
experience by building or installing new tools and functionalities.
- **[Configuration](./reference/configuration.md):** Fine-tune Gemini CLI to
match your preferences and project requirements with detailed settings and
options.
- **[Command Reference](./reference/commands.md):** A complete guide to all
available CLI commands, flags, and interactive prompts.
- **[Resources](./resources/faq.md):** Find answers to frequently asked
questions, troubleshooting tips, and support information.
Support, release history, and legal information.
We're excited for you to experience a new level of terminal productivity with
Gemini CLI!
- **[FAQ](./resources/faq.md):** Answers to frequently asked questions.
- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing
details.
- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and
terms.
- **[Troubleshooting](./resources/troubleshooting.md):** Common issues and
solutions.
- **[Uninstall](./resources/uninstall.md):** How to uninstall Gemini CLI.
## Development
- **[Contribution guide](/docs/contributing):** How to contribute to Gemini CLI.
- **[Integration testing](./integration-tests.md):** Running integration tests.
- **[Issue and PR automation](./issue-and-pr-automation.md):** Automation for
issues and pull requests.
- **[Local development](./local-development.md):** Setting up a local
development environment.
- **[NPM package structure](./npm.md):** The structure of the NPM packages.
## Releases
- **[Release notes](./changelogs/index.md):** Release notes for all versions.
- **[Stable release](./changelogs/latest.md):** The latest stable release.
- **[Preview release](./changelogs/preview.md):** The latest preview release.
+16
View File
@@ -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`
+14 -9
View File
@@ -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":"<your_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":"<your_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
```
+5
View File
@@ -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,
+10 -12
View File
@@ -35,13 +35,19 @@ 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(
{
// Global ignores
ignores: [
'node_modules/*',
'**/node_modules/**',
'eslint.config.js',
'packages/**/dist/**',
'bundle/**',
@@ -50,7 +56,7 @@ export default tseslint.config(
'dist/**',
'evals/**',
'packages/test-utils/**',
'.gemini/skills/**',
'.gemini/**',
'**/*.d.ts',
],
},
@@ -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',
{
+18 -1
View File
@@ -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<string, string>;
+25
View File
@@ -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
},
});
});
-4
View File
@@ -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',
+2 -4
View File
@@ -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: {},
+8 -60
View File
@@ -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');
+17 -1
View File
@@ -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<string, any>;
params?: {
settings?: ForbiddenToolSettings & Record<string, unknown>;
[key: string]: unknown;
};
prompt: string;
timeout?: number;
files?: Record<string, string>;
+4
View File
@@ -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',
@@ -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}}]}
+32
View File
@@ -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');
});
});
+7 -3
View File
@@ -34,16 +34,20 @@ describe('extension install', () => {
writeFileSync(testServerPath, extension);
try {
const result = await rig.runCommand(
['extensions', 'install', `${rig.testDir!}`],
['--debug', 'extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension-install');
const listResult = await rig.runCommand(['extensions', 'list']);
const listResult = await rig.runCommand([
'--debug',
'extensions',
'list',
]);
expect(listResult).toContain('test-extension-install');
writeFileSync(testServerPath, extensionUpdate);
const updateResult = await rig.runCommand(
['extensions', 'update', `test-extension-install`],
['--debug', 'extensions', 'update', `test-extension-install`],
{ stdin: 'y\n' },
);
expect(updateResult).toContain('0.0.2');
+1 -1
View File
@@ -66,7 +66,7 @@ describe('extension reloading', () => {
}
const result = await rig.runCommand(
['extensions', 'install', `${rig.testDir!}`],
['--debug', 'extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
+214 -3
View File
@@ -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);
},
);
});
});
+1 -1
View File
@@ -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",
@@ -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<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
() => vi.fn(),
);
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
vi.fn(),
);
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
vi.fn(),
);
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
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({
@@ -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();
},
+1
View File
@@ -54,6 +54,7 @@ export async function getMcpServersFromConfig(
return;
}
mcpServers[key] = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...server,
extension,
};
+72
View File
@@ -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<string, MCPServerConfig> = {
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<string, MCPServerConfig> = {
serverA: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...localMcpServers['serverA'],
includeTools: ['local_tool'],
},
@@ -2225,6 +2273,30 @@ describe('loadCliConfig tool exclusions', () => {
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should exclude ask_user in interactive mode when --acp is provided', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--acp'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should exclude ask_user in interactive mode when --experimental-acp is provided', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--experimental-acp'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => {
process.stdin.isTTY = false;
process.argv = [
+110 -3
View File
@@ -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;
@@ -115,6 +122,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<CliArgs> {
@@ -158,6 +195,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',
@@ -335,6 +386,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;
});
@@ -420,6 +474,7 @@ export interface LoadCliConfigOptions {
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
disabled?: string[];
};
worktreeSettings?: WorktreeSettings;
}
export async function loadCliConfig(
@@ -431,6 +486,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';
}
@@ -649,12 +707,16 @@ export async function loadCliConfig(
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive) {
if (!interactive || isAcpMode) {
// The Policy Engine natively handles headless safety by translating ASK_USER
// decisions to DENY. However, we explicitly block ask_user here to guarantee
// it can never be allowed via a high-priority policy rule when no human is present.
// We also exclude it in ACP mode as IDEs intercept tool calls and ask for permission,
// breaking conversational flows.
extraExcludes.push(ASK_USER_TOOL_NAME);
}
@@ -770,7 +832,6 @@ export async function loadCliConfig(
}
}
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
let clientName: string | undefined = undefined;
if (isAcpMode) {
const ide = detectIdeFromEnv();
@@ -799,6 +860,7 @@ export async function loadCliConfig(
importFormat: settings.context?.importFormat,
debugMode,
question,
worktreeSettings,
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
@@ -940,3 +1002,48 @@ function mergeExcludeTools(
]);
return Array.from(allExcludeTools);
}
async function resolveWorktreeSettings(
cwd: string,
): Promise<WorktreeSettings | undefined> {
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,
};
}
@@ -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,
);
});
});
});
+4 -11
View File
@@ -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;
}
}
+18 -10
View File
@@ -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',
@@ -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();
}
@@ -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',
},
+4
View File
@@ -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.
@@ -538,8 +538,32 @@ describe('SettingsSchema', () => {
}
};
const visitJsonSchema = (jsonSchema: Record<string, unknown>) => {
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<string, unknown>).forEach((prop) =>
visitJsonSchema(prop as Record<string, unknown>),
);
}
const items = jsonSchema['items'];
if (items && typeof items === 'object' && !Array.isArray(items)) {
visitJsonSchema(items as Record<string, unknown>);
}
};
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)) {
+39 -1
View File
@@ -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:
+4
View File
@@ -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', () => ({
@@ -1506,6 +1509,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;
+43 -15
View File
@@ -9,6 +9,7 @@ import {
WarningPriority,
type Config,
type ResumedSessionData,
type WorktreeInfo,
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
@@ -31,6 +32,7 @@ import {
ValidationRequiredError,
type AdminControlsSettings,
debugLogger,
isHeadlessMode,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -63,6 +65,7 @@ import {
registerTelemetryConfig,
setupSignalHandlers,
} from './utils/cleanup.js';
import { setupWorktree } from './utils/worktreeSetup.js';
import {
cleanupToolOutputFiles,
cleanupExpiredSessions,
@@ -210,6 +213,37 @@ export async function main() {
const settings = loadSettings();
loadSettingsHandle?.end();
// If a worktree is requested and enabled, set it up early.
// This must be awaited before any other async tasks that depend on CWD (like loadCliConfig)
// because setupWorktree calls process.chdir().
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
let worktreeInfo: WorktreeInfo | undefined;
if (requestedWorktree !== undefined) {
const worktreeHandle = startupProfiler.start('setup_worktree');
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
worktreeHandle?.end();
}
const cleanupOpsHandle = startupProfiler.start('cleanup_ops');
Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
])
.catch((e) => {
debugLogger.error('Early cleanup failed:', e);
})
.finally(() => {
cleanupOpsHandle?.end();
});
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argvPromise = parseArguments(settings.merged).finally(() => {
parseArgsHandle?.end();
});
const rawStartupWarningsPromise = getStartupWarnings();
// Report settings errors once during startup
settings.errors.forEach((error) => {
coreEvents.emitFeedback('warning', error.message);
@@ -223,15 +257,7 @@ export async function main() {
);
});
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
]);
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argv = await parseArguments(settings.merged);
parseArgsHandle?.end();
const argv = await argvPromise;
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
@@ -271,6 +297,7 @@ export async function main() {
const isDebugMode = cliConfig.isDebugMode(argv);
const consolePatcher = new ConsolePatcher({
stderr: true,
interactive: isHeadlessMode() ? false : true,
debugMode: isDebugMode,
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
@@ -426,6 +453,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();
@@ -457,12 +485,10 @@ export async function main() {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
// Cleanup sessions after config initialization
try {
await cleanupExpiredSessions(config, settings.merged);
} catch (e) {
// Launch cleanup expired sessions as a background task
cleanupExpiredSessions(config, settings.merged).catch((e) => {
debugLogger.error('Failed to cleanup expired sessions:', e);
}
});
if (config.getListExtensions()) {
debugLogger.log('Installed extensions:');
@@ -514,7 +540,9 @@ export async function main() {
});
}
const terminalHandle = startupProfiler.start('setup_terminal');
await setupTerminalAndTheme(config, settings);
terminalHandle?.end();
const initAppHandle = startupProfiler.start('initialize_app');
const initializationResult = await initializeApp(config, settings);
@@ -538,7 +566,7 @@ export async function main() {
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const rawStartupWarnings = await getStartupWarnings();
const rawStartupWarnings = await rawStartupWarningsPromise;
const startupWarnings: StartupWarning[] = [
...rawStartupWarnings.map((message) => ({
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
+2
View File
@@ -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', () => ({
@@ -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);
+1
View File
@@ -65,6 +65,7 @@ export async function runNonInteractive({
return promptIdContext.run(prompt_id, async () => {
const consolePatcher = new ConsolePatcher({
stderr: true,
interactive: false,
debugMode: config.getDebugMode(),
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
@@ -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,
+12 -2
View File
@@ -11,7 +11,11 @@ import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { AppContainer } from '../ui/AppContainer.js';
import { renderWithProviders, type RenderInstance } from './render.js';
import {
renderWithProviders,
type RenderInstance,
persistentStateMock,
} from './render.js';
import {
makeFakeConfig,
type Config,
@@ -180,6 +184,11 @@ export class AppRig {
}
async initialize() {
persistentStateMock.setData({
terminalSetupPromptShown: true,
tipsShown: 10,
});
this.setupEnvironment();
resetSettingsCacheForTesting();
this.settings = this.createRigSettings();
@@ -226,6 +235,8 @@ export class AppRig {
private setupEnvironment() {
// Stub environment variables to avoid interference from developer's machine
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
vi.stubEnv('TERM_PROGRAM', 'other');
vi.stubEnv('VSCODE_GIT_IPC_HANDLE', '');
if (this.options.fakeResponsesPath) {
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
MockShellExecutionService.setPassthrough(false);
@@ -291,7 +302,6 @@ export class AppRig {
const newContentGeneratorConfig = {
authType: authMethod,
proxy: gcConfig.getProxy(),
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
};
@@ -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,
@@ -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<string>(),
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;
}
}
@@ -44,6 +44,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): 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),
+14 -39
View File
@@ -12,24 +12,18 @@ import { waitFor } from './async.js';
describe('render', () => {
it('should render a component', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<Text>Hello World</Text>,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<Text>Hello World</Text>);
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(
<Text>Hello</Text>,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello\n');
await act(async () => {
rerender(<Text>World</Text>);
});
await act(async () => rerender(<Text>World</Text>));
await waitUntilReady();
expect(lastFrame()).toBe('World\n');
unmount();
@@ -42,10 +36,8 @@ describe('render', () => {
return <Text>Hello</Text>;
}
const { unmount, waitUntilReady } = render(<TestComponent />);
await waitUntilReady();
const { unmount } = await render(<TestComponent />);
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();
+36 -40
View File
@@ -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<void>;
};
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<RenderInstance, 'capturedOverflowState' | 'capturedOverflowActions'>
> => {
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<void>;
}
> => {
): Promise<RenderWithProvidersInstance> => {
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
{
@@ -751,7 +747,10 @@ export const renderWithProviders = async (
</AppContext.Provider>
);
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<Result, Props>(
export async function renderHook<Result, Props>(
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<void>;
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<Result, Props>(
let waitUntilReady: () => Promise<void> = async () => {};
let generateSvg: () => string = () => '';
act(() => {
const renderResult = render(
<Wrapper>
<TestComponent renderCallback={renderCallback} props={currentProps} />
</Wrapper>,
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
generateSvg = renderResult.generateSvg;
});
const renderResult = await render(
<Wrapper>
<TestComponent renderCallback={renderCallback} props={currentProps} />
</Wrapper>,
);
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<Result, Props>(
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
let renderResult: ReturnType<typeof render>;
let renderResult: RenderWithProvidersInstance;
await act(async () => {
renderResult = await renderWithProviders(
-2
View File
@@ -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 || [],
+45 -89
View File
@@ -94,14 +94,10 @@ describe('App', () => {
};
it('should render main content and composer when not quitting', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
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(
<App />,
{
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
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(
<App />,
{
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
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(
<App />,
{
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
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(
<App />,
{
uiState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
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(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
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(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
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(
<App />,
{
uiState: stateWithConfirmingTool,
config: configWithExperiment,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
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(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
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(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
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(
<App />,
{
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
File diff suppressed because it is too large Load Diff
@@ -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(
<IdeIntegrationNudge {...defaultProps} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
@@ -72,8 +72,6 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// "Yes" is the first option and selected by default usually.
await act(async () => {
stdin.write('\r');
@@ -93,8 +91,6 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Navigate down to "No (esc)"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
@@ -119,8 +115,6 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
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', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Press Escape
await act(async () => {
stdin.write('\u001B');
@@ -178,8 +170,6 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain(
@@ -2,10 +2,13 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
@@ -31,9 +34,6 @@ Tips for getting started:
@@ -47,10 +47,13 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
"Notifications
Footer
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
@@ -64,12 +67,12 @@ Composer
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
@@ -107,10 +110,13 @@ DialogManager
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
@@ -140,9 +146,6 @@ HistoryItemDisplay
Notifications
Composer
"
@@ -73,23 +73,21 @@ describe('ApiAuthDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with a defaultValue', async () => {
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
defaultValue="test-key"
/>,
);
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(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
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(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
error="Invalid API Key"
/>,
);
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(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// Call 0 is ApiAuthDialog (isActive: true)
// Call 1 is TextInput (isActive: true, priority: true)
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
+17 -58
View File
@@ -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(
<AuthDialog {...props} />,
);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
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(
<AuthDialog {...props} />,
);
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(
<AuthDialog {...props} />,
);
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(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -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(
<AuthInProgress onTimeout={onTimeout} />,
);
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(
<AuthInProgress onTimeout={onTimeout} />,
);
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(
<AuthInProgress onTimeout={onTimeout} />,
);
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(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
vi.advanceTimersByTime(180000);
@@ -116,10 +113,7 @@ describe('AuthInProgress', () => {
});
it('clears timer on unmount', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const { unmount } = await render(<AuthInProgress onTimeout={onTimeout} />);
await act(async () => {
unmount();
@@ -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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutUrl}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutLinkText}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
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(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -45,25 +45,23 @@ describe('LoginWithGoogleRestartDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('calls onDismiss when escape is pressed', async () => {
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
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(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
+113 -90
View File
@@ -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);
});
});
});
@@ -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}`,
@@ -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<boolean>,
) {
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.`,
@@ -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(),
},
@@ -25,10 +25,9 @@ describe('AboutBox', () => {
};
it('renders with required props', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AboutBox {...defaultProps} />,
);
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(
<AboutBox {...props} />,
);
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(
<AboutBox {...props} />,
);
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(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('api-key');
unmount();
@@ -17,15 +17,14 @@ describe('AdminSettingsChangedDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AdminSettingsChangedDialog />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('restarts on "r" key press', async () => {
const { stdin, waitUntilReady } = await renderWithProviders(
const { stdin } = await renderWithProviders(
<AdminSettingsChangedDialog />,
{
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(
<AdminSettingsChangedDialog />,
{
uiActions: {
@@ -51,7 +49,6 @@ describe('AdminSettingsChangedDialog', () => {
},
},
);
await waitUntilReady();
act(() => {
stdin.write(key);
@@ -126,7 +126,6 @@ describe('AgentConfigDialog', () => {
/>,
{ settings, uiState: { mainAreaWidth: 100 } },
);
await result.waitUntilReady();
return result;
};
@@ -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(
<AlternateBufferQuittingDisplay />,
{
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(
<AlternateBufferQuittingDisplay />,
{
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(
<AlternateBufferQuittingDisplay />,
{
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(
<AlternateBufferQuittingDisplay />,
{
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(
<AlternateBufferQuittingDisplay />,
{
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(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -230,7 +225,6 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');
unmount();
});
@@ -29,10 +29,9 @@ describe('<AnsiOutputText />', () => {
createAnsiToken({ text: 'world!' }),
],
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame().trim()).toBe('Hello, world!');
unmount();
});
@@ -47,10 +46,9 @@ describe('<AnsiOutputText />', () => {
{ 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(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame().trim()).toBe(text);
unmount();
});
@@ -61,10 +59,9 @@ describe('<AnsiOutputText />', () => {
{ 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(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame().trim()).toBe(text);
unmount();
});
@@ -76,10 +73,9 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Third line' })],
[createAnsiToken({ text: '' })],
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
const lines = output.split('\n');
@@ -96,10 +92,9 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={data} availableTerminalHeight={2} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
@@ -115,10 +110,9 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={data} maxLines={2} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
@@ -135,7 +129,7 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 4' })],
];
// availableTerminalHeight=3, maxLines=2 => show 2 lines
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText
data={data}
availableTerminalHeight={3}
@@ -143,7 +137,6 @@ describe('<AnsiOutputText />', () => {
width={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
@@ -156,10 +149,9 @@ describe('<AnsiOutputText />', () => {
for (let i = 0; i < 1000; i++) {
largeData.push([createAnsiToken({ text: `Line ${i}` })]);
}
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={largeData} width={80} />,
);
await waitUntilReady();
// We are just checking that it renders something without crashing.
expect(lastFrame()).toBeDefined();
unmount();
@@ -10,6 +10,7 @@ import {
} from '../../test-utils/render.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
import crypto from 'node:crypto';
vi.mock('../utils/terminalSetup.js', () => ({
@@ -27,13 +28,12 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
@@ -50,13 +50,12 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('There are capacity issues');
expect(lastFrame()).toMatchSnapshot();
@@ -72,13 +71,12 @@ describe('<AppHeader />', () => {
},
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Banner');
expect(lastFrame()).toMatchSnapshot();
@@ -103,13 +101,12 @@ describe('<AppHeader />', () => {
},
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
@@ -129,13 +126,12 @@ describe('<AppHeader />', () => {
// and interfering with the expected persistentState.set call.
persistentStateMock.setData({ tipsShown: 10 });
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(persistentStateMock.set).toHaveBeenCalledWith(
'defaultBannerShownCount',
@@ -159,13 +155,12 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('First line\\nSecond line');
unmount();
@@ -183,13 +178,12 @@ describe('<AppHeader />', () => {
persistentStateMock.setData({ tipsShown: 5 });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips');
expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6);
@@ -206,13 +200,12 @@ describe('<AppHeader />', () => {
persistentStateMock.setData({ tipsShown: 10 });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Tips');
unmount();
@@ -234,7 +227,6 @@ describe('<AppHeader />', () => {
const session1 = await renderWithProviders(<AppHeader version="1.0.0" />, {
uiState,
});
await session1.waitUntilReady();
expect(session1.lastFrame()).toContain('Tips');
expect(persistentStateMock.get('tipsShown')).toBe(10);
@@ -245,9 +237,31 @@ describe('<AppHeader />', () => {
<AppHeader version="1.0.0" />,
{},
);
await session2.waitUntilReady();
expect(session2.lastFrame()).not.toContain('Tips');
session2.unmount();
});
it('should render the full logo when logged out', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: undefined,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState: {
terminalWidth: 120,
},
},
);
await waitUntilReady();
// Check for block characters from the logo
expect(lastFrame()).toContain('▗█▀▀▜▙');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
+86 -51
View File
@@ -19,6 +19,9 @@ import { CliSpinner } from './CliSpinner.js';
import { isAppleTerminal } from '@google/gemini-cli-core';
import { longAsciiLogoCompactText } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
interface AppHeaderProps {
version: string;
showDetails?: boolean;
@@ -41,6 +44,18 @@ const MAC_TERMINAL_ICON = `▝▜▄
`;
/**
* The horizontal padding (in columns) required for metadata (version, identity, etc.)
* when rendered alongside the ASCII logo.
*/
const LOGO_METADATA_PADDING = 20;
/**
* The terminal width below which we switch to a narrow/column layout to prevent
* UI elements from wrapping or overlapping.
*/
const NARROW_TERMINAL_BREAKPOINT = 60;
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
@@ -49,70 +64,90 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
const authType = config.getContentGeneratorConfig()?.authType;
const loggedOut = !authType;
const showHeader = !(
settings.merged.ui.hideBanner || config.getScreenReader()
);
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
if (!showDetails) {
return (
<Box flexDirection="column">
{showHeader && (
<Box
flexDirection="row"
marginTop={1}
marginBottom={1}
paddingLeft={2}
>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
</Box>
</Box>
let logoTextArt = '';
if (loggedOut) {
const widthOfLongLogo =
getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING;
if (terminalWidth >= widthOfLongLogo) {
logoTextArt = longAsciiLogoCompactText.trim();
}
}
// If the terminal is too narrow to fit the icon and metadata (especially long nightly versions)
// side-by-side, we switch to column mode to prevent wrapping.
const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT;
const renderLogo = () => (
<Box flexDirection="row">
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
{logoTextArt && (
<Box marginLeft={3}>
<Text color={theme.text.primary}>{logoTextArt}</Text>
</Box>
)}
</Box>
);
const renderMetadata = (isBelow = false) => (
<Box marginLeft={isBelow ? 0 : 2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
);
}
{showDetails && (
<>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</>
)}
</Box>
);
const useColumnLayout = !!logoTextArt || isNarrow;
return (
<Box flexDirection="column">
{showHeader && (
<Box flexDirection="row" marginTop={1} marginBottom={1} paddingLeft={2}>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</Box>
<Box
flexDirection={useColumnLayout ? 'column' : 'row'}
marginTop={1}
marginBottom={1}
paddingLeft={1}
>
{renderLogo()}
{useColumnLayout ? (
<Box marginTop={1}>{renderMetadata(true)}</Box>
) : (
renderMetadata(false)
)}
</Box>
)}
@@ -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(
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for AUTO_EDIT mode with plan enabled', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.AUTO_EDIT}
allowPlanMode={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for PLAN mode', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for YOLO mode', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode with plan enabled', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.DEFAULT}
allowPlanMode={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
+29 -8
View File
@@ -16,14 +16,14 @@ export const shortAsciiLogo = `
`;
export const longAsciiLogo = `
`;
export const tinyAsciiLogo = `
@@ -36,3 +36,24 @@ export const tinyAsciiLogo = `
`;
export const shortAsciiLogoCompactText = `
`;
export const longAsciiLogoCompactText = `
`;
export const tinyAsciiLogoCompactText = `
`;
@@ -48,7 +48,7 @@ describe('AskUserDialog', () => {
];
it('renders question and options', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -58,7 +58,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -397,7 +396,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -407,12 +406,11 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('hides progress header for single question', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -422,12 +420,11 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('shows keyboard hints', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -437,7 +434,6 @@ describe('AskUserDialog', () => {
{ 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(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -592,7 +587,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -736,7 +730,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -746,7 +740,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -759,7 +752,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -769,7 +762,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -820,7 +812,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -830,7 +822,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -145,7 +145,7 @@ describe('<BackgroundShellDisplay />', () => {
it('renders the output of the active shell', async () => {
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -158,7 +158,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -166,7 +165,7 @@ describe('<BackgroundShellDisplay />', () => {
it('renders tabs for multiple shells', async () => {
const width = 100;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -179,7 +178,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -187,7 +185,7 @@ describe('<BackgroundShellDisplay />', () => {
it('highlights the focused state', async () => {
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -200,7 +198,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -208,7 +205,7 @@ describe('<BackgroundShellDisplay />', () => {
it('resizes the PTY on mount and when dimensions change', async () => {
const width = 80;
const { rerender, waitUntilReady, unmount } = render(
const { rerender, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -221,7 +218,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
@@ -241,7 +237,6 @@ describe('<BackgroundShellDisplay />', () => {
/>
</ScrollProvider>,
);
await waitUntilReady();
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
@@ -253,7 +248,7 @@ describe('<BackgroundShellDisplay />', () => {
it('renders the process list when isListOpenProp is true', async () => {
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -266,7 +261,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -274,7 +268,7 @@ describe('<BackgroundShellDisplay />', () => {
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(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -287,19 +281,16 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
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('<BackgroundShellDisplay />', () => {
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(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -321,7 +312,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
// Initial state: shell1 (active) is highlighted
@@ -329,13 +319,11 @@ describe('<BackgroundShellDisplay />', () => {
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('<BackgroundShellDisplay />', () => {
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(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -356,12 +344,10 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
await act(async () => {
simulateKey({ name: 'k', ctrl: true });
});
await waitUntilReady();
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
unmount();
@@ -370,7 +356,7 @@ describe('<BackgroundShellDisplay />', () => {
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(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -383,7 +369,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -402,7 +387,7 @@ describe('<BackgroundShellDisplay />', () => {
mockShells.set(exitedShell.pid, exitedShell);
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -415,7 +400,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -18,10 +18,9 @@ describe('<Checklist />', () => {
];
it('renders nothing when list is empty', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Checklist title="Test List" items={[]} isExpanded={true} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
@@ -30,15 +29,14 @@ describe('<Checklist />', () => {
{ status: 'completed', label: 'Task 1' },
{ status: 'cancelled', label: 'Task 2' },
];
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders summary view correctly (collapsed)', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Checklist
title="Test List"
items={items}
@@ -46,12 +44,11 @@ describe('<Checklist />', () => {
toggleHint="toggle me"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders expanded view correctly', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Checklist
title="Test List"
items={items}
@@ -59,7 +56,6 @@ describe('<Checklist />', () => {
toggleHint="toggle me"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -68,10 +64,9 @@ describe('<Checklist />', () => {
{ status: 'completed', label: 'Task 1' },
{ status: 'pending', label: 'Task 2' },
];
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -17,8 +17,7 @@ describe('<ChecklistItem />', () => {
{ status: 'cancelled', label: 'Skipped this' },
{ status: 'blocked', label: 'Blocked this' },
] as ChecklistItemData[])('renders %s item correctly', async (item) => {
const { lastFrame, waitUntilReady } = render(<ChecklistItem item={item} />);
await waitUntilReady();
const { lastFrame } = await render(<ChecklistItem item={item} />);
expect(lastFrame()).toMatchSnapshot();
});
@@ -28,12 +27,11 @@ describe('<ChecklistItem />', () => {
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(
<Box width={30}>
<ChecklistItem item={item} wrap="truncate" />
</Box>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -43,12 +41,11 @@ describe('<ChecklistItem />', () => {
label:
'This is a very long text that should wrap because the default behavior is wrapping',
};
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Box width={30}>
<ChecklistItem item={item} />
</Box>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -17,10 +17,7 @@ describe('<CliSpinner />', () => {
it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => {
expect(debugState.debugNumAnimatedComponents).toBe(0);
const { waitUntilReady, unmount } = await renderWithProviders(
<CliSpinner />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<CliSpinner />);
expect(debugState.debugNumAnimatedComponents).toBe(1);
unmount();
expect(debugState.debugNumAnimatedComponents).toBe(0);
@@ -28,11 +25,9 @@ describe('<CliSpinner />', () => {
it('should not render when showSpinner is false', async () => {
const settings = createMockSettings({ ui: { showSpinner: false } });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<CliSpinner />,
{ settings },
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<CliSpinner />, {
settings,
});
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -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(
<ColorsDisplay activeTheme={mockTheme} />,
);
await waitUntilReady();
const output = lastFrame();
// Check for title and description
@@ -251,7 +251,7 @@ const renderComposer = async (
config = createMockConfig(),
uiActions = createMockUIActions(),
) => {
const result = render(
const result = await render(
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
@@ -262,7 +262,6 @@ const renderComposer = async (
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
await result.waitUntilReady();
// Wait for shortcuts hint debounce if using fake timers
if (vi.isFakeTimers()) {
@@ -43,10 +43,7 @@ describe('ConfigInitDisplay', () => {
});
it('renders initial state', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ConfigInitDisplay />,
);
await waitUntilReady();
const { lastFrame } = await renderWithProviders(<ConfigInitDisplay />);
expect(lastFrame()).toMatchSnapshot();
});
@@ -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(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
{
@@ -55,14 +54,13 @@ describe('ConsentPrompt', () => {
it('renders a ReactNode prompt directly', async () => {
const prompt = <Text>Are you sure?</Text>;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
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(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
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(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
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(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedRadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
@@ -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(
<ConsoleSummaryDisplay errorCount={0} />,
);
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(
<ConsoleSummaryDisplay errorCount={count} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(expectedText);
expect(output).toContain('✖');
@@ -26,8 +26,7 @@ const renderWithWidth = async (
props: React.ComponentProps<typeof ContextSummaryDisplay>,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
const result = render(<ContextSummaryDisplay {...props} />);
await result.waitUntilReady();
const result = await render(<ContextSummaryDisplay {...props} />);
return result;
};
@@ -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(
<ContextUsageDisplay
promptTokenCount={5000}
model="gemini-pro"
terminalWidth={120}
/>,
);
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(
<ContextUsageDisplay
promptTokenCount={0}
model="gemini-pro"
terminalWidth={120}
/>,
);
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(
<ContextUsageDisplay
promptTokenCount={2000}
model="gemini-pro"
@@ -55,7 +53,6 @@ describe('ContextUsageDisplay', () => {
/>,
{ 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(
<ContextUsageDisplay
promptTokenCount={8000}
model="gemini-pro"
terminalWidth={120}
/>,
);
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(
<ContextUsageDisplay
promptTokenCount={10000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('100% used');
unmount();
@@ -22,8 +22,7 @@ describe('CopyModeWarning', () => {
mockUseUIState.mockReturnValue({
copyModeEnabled: false,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<CopyModeWarning />);
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(<CopyModeWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<CopyModeWarning />);
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');
@@ -242,8 +242,7 @@ describe('DebugProfiler Component', () => {
showDebugProfiler: false,
constrainHeight: false,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<DebugProfiler />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -257,8 +256,7 @@ describe('DebugProfiler Component', () => {
profiler.totalIdleFrames = 5;
profiler.totalFlickerFrames = 2;
const { lastFrame, waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<DebugProfiler />);
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(<DebugProfiler />);
await waitUntilReady();
const { waitUntilReady, unmount } = await render(<DebugProfiler />);
await act(async () => {
coreEvents.emitModelChanged('new-model');
@@ -295,8 +292,7 @@ describe('DebugProfiler Component', () => {
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
const { waitUntilReady, unmount } = await render(<DebugProfiler />);
await act(async () => {
appEvents.emit(AppEvent.SelectionWarning);
@@ -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(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
{
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(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
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(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
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(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
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(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
@@ -104,11 +104,10 @@ describe('DialogManager', () => {
};
it('renders nothing by default', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<DialogManager {...defaultProps} />,
{ uiState: baseUiState as Partial<UIState> 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(
<DialogManager {...defaultProps} />,
{
uiState: {
@@ -206,7 +205,6 @@ describe('DialogManager', () => {
} as Partial<UIState> as UIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain(expectedComponent);
unmount();
},
@@ -55,27 +55,25 @@ describe('EditorSettingsDialog', () => {
renderWithProviders(ui);
it('renders correctly', async () => {
const { lastFrame, waitUntilReady } = await renderWithProvider(
const { lastFrame } = await renderWithProvider(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={mockSettings}
onExit={vi.fn()}
/>,
);
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(
<EditorSettingsDialog
onSelect={onSelect}
settings={mockSettings}
onExit={vi.fn()}
/>,
);
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(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={settingsWithOtherScope}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
const frame = lastFrame() || '';
if (!frame.includes('(Also modified')) {
@@ -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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
@@ -38,33 +38,30 @@ describe('EmptyWalletDialog', () => {
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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
resetTime="3:45 PM"
onChoice={mockOnChoice}
/>,
);
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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
onGetCredits={mockOnGetCredits}
/>,
);
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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
onChoice={mockOnChoice}
/>,
);
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(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
@@ -440,36 +440,38 @@ Implement a comprehensive authentication system with multiple providers.
return <>{children}</>;
};
const { stdin, lastFrame } = await renderWithProviders(
<BubbleListener>
<ExitPlanModeDialog
planPath={mockPlanFullPath}
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
/>
</BubbleListener>,
{
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(
<BubbleListener>
<ExitPlanModeDialog
planPath={mockPlanFullPath}
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
/>
</BubbleListener>,
{
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 () => {
@@ -24,8 +24,7 @@ describe('ExitWarning', () => {
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<ExitWarning />);
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(<ExitWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<ExitWarning />);
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(<ExitWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<ExitWarning />);
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(<ExitWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<ExitWarning />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -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(
<FolderTrustDialog onSelect={vi.fn()} />,
);
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(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -85,7 +84,6 @@ describe('FolderTrustDialog', () => {
},
);
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(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -116,7 +114,6 @@ describe('FolderTrustDialog', () => {
},
);
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(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -148,7 +145,6 @@ describe('FolderTrustDialog', () => {
},
);
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(
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
);
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(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
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(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
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(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await waitUntilReady();
// Unmount immediately (before 250ms)
unmount();
@@ -292,7 +282,6 @@ describe('FolderTrustDialog', () => {
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
);
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(
<FolderTrustDialog onSelect={vi.fn()} />,
);
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(
<FolderTrustDialog onSelect={vi.fn()} />,
);
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(
<FolderTrustDialog onSelect={vi.fn()} />,
);
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(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -356,7 +342,6 @@ describe('FolderTrustDialog', () => {
{ 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(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
);
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(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
);
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(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -447,7 +430,6 @@ describe('FolderTrustDialog', () => {
},
);
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(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -478,7 +460,6 @@ describe('FolderTrustDialog', () => {
{ width: 100, uiState: { terminalHeight: 40 } },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('cmd-with-ansi');
+304 -424
View File
@@ -138,33 +138,25 @@ describe('<Footer />', () => {
});
it('renders the component', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
});
expect(lastFrame()).toBeDefined();
unmount();
});
describe('path display', () => {
it('should display a shortened path on a narrow terminal', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
});
const output = lastFrame();
expect(output).toBeDefined();
// Should contain some part of the path, likely shortened
@@ -173,15 +165,11 @@ describe('<Footer />', () => {
});
it('should use wide layout at 80 columns', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 80,
uiState: { sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 80,
uiState: { sessionStats: mockSessionStats },
});
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain(path.join('make', 'it'));
@@ -189,28 +177,24 @@ describe('<Footer />', () => {
});
it('should not truncate high-priority items on narrow terminals (regression)', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 60,
uiState: {
sessionStats: mockSessionStats,
},
settings: createMockSettings({
general: {
vimMode: true,
},
ui: {
footer: {
showLabels: true,
items: ['workspace', 'model-name'],
},
},
}),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 60,
uiState: {
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
settings: createMockSettings({
general: {
vimMode: true,
},
ui: {
footer: {
showLabels: true,
items: ['workspace', 'model-name'],
},
},
}),
});
const output = lastFrame();
// [INSERT] is high priority and should be fully visible
// (Note: VimModeProvider defaults to 'INSERT' mode when enabled)
@@ -222,168 +206,140 @@ describe('<Footer />', () => {
});
it('displays the branch name when provided', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
});
expect(lastFrame()).toContain(defaultProps.branchName);
unmount();
});
it('does not display the branch name when not provided', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { branchName: undefined, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { branchName: undefined, sessionStats: mockSessionStats },
});
expect(lastFrame()).not.toContain('Branch');
unmount();
});
it('displays the model name and context percentage', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
currentModel: defaultProps.model,
sessionStats: {
...mockSessionStats,
lastPromptTokenCount: 1000,
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
currentModel: defaultProps.model,
sessionStats: {
...mockSessionStats,
lastPromptTokenCount: 1000,
},
},
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
},
);
await waitUntilReady();
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% used/);
unmount();
});
it('displays the usage indicator when usage is low', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
);
await waitUntilReady();
});
expect(lastFrame()).toContain('85%');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
it('hides the usage indicator when usage is not near limit', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
);
await waitUntilReady();
});
expect(normalizeFrame(lastFrame())).not.toContain('used');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
it('displays "Limit reached" message when remaining is 0', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
);
await waitUntilReady();
});
expect(lastFrame()?.toLowerCase()).toContain('limit reached');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 99,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 99,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+%/);
expect(lastFrame()).not.toContain('context used');
@@ -392,33 +348,25 @@ describe('<Footer />', () => {
describe('sandbox and trust info', () => {
it('should display untrusted when isTrustedFolder is false', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain('untrusted');
unmount();
});
it('should display custom sandbox info when SANDBOX env is set', async () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
isTrustedFolder: undefined,
sessionStats: mockSessionStats,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
isTrustedFolder: undefined,
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
});
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
unmount();
@@ -427,15 +375,11 @@ describe('<Footer />', () => {
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
});
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
unmount();
@@ -444,15 +388,11 @@ describe('<Footer />', () => {
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', async () => {
// Clear any SANDBOX env var that might be set.
vi.stubEnv('SANDBOX', '');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain('no sandbox');
vi.unstubAllEnvs();
unmount();
@@ -460,15 +400,11 @@ describe('<Footer />', () => {
it('should prioritize untrusted message over sandbox info', async () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain('untrusted');
expect(lastFrame()).not.toMatch(/test-sandbox/s);
vi.unstubAllEnvs();
@@ -478,22 +414,18 @@ describe('<Footer />', () => {
describe('footer configuration filtering (golden snapshots)', () => {
it('renders complete footer with all sections visible (baseline)', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'complete-footer-wide',
);
@@ -523,47 +455,39 @@ describe('<Footer />', () => {
});
it('renders footer with only model info hidden (partial filtering)', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
unmount();
});
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'footer-only-sandbox',
);
@@ -571,64 +495,52 @@ describe('<Footer />', () => {
});
it('hides the context percentage when hideContextPercentage is true', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: true,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: true,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% used/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% used/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'complete-footer-narrow',
);
@@ -714,60 +626,48 @@ describe('<Footer />', () => {
});
it('hides error summary in low verbosity mode out of dev mode', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
);
await waitUntilReady();
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
});
expect(lastFrame()).not.toContain('F12 for details');
unmount();
});
it('shows error summary in low verbosity mode in dev mode', async () => {
mocks.isDevelopment = true;
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
);
await waitUntilReady();
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
});
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
});
it('shows error summary in full verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
);
await waitUntilReady();
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
});
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
@@ -776,25 +676,21 @@ describe('<Footer />', () => {
describe('Footer Custom Items', () => {
it('renders items in the specified order', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
},
settings: createMockSettings({
ui: {
footer: {
items: ['model-name', 'workspace'],
},
},
}),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
settings: createMockSettings({
ui: {
footer: {
items: ['model-name', 'workspace'],
},
},
}),
});
const output = lastFrame();
const modelIdx = output.indexOf('/model');
@@ -804,28 +700,24 @@ describe('<Footer />', () => {
});
it('renders multiple items with proper alignment', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: 'main',
},
settings: createMockSettings({
vimMode: {
vimMode: true,
},
ui: {
footer: {
items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
},
},
}),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: 'main',
},
);
await waitUntilReady();
settings: createMockSettings({
vimMode: {
vimMode: true,
},
ui: {
footer: {
items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
@@ -862,25 +754,21 @@ describe('<Footer />', () => {
});
it('does not render items that are conditionally hidden', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: undefined, // No branch
},
settings: createMockSettings({
ui: {
footer: {
items: ['workspace', 'git-branch', 'model-name'],
},
},
}),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: undefined, // No branch
},
);
await waitUntilReady();
settings: createMockSettings({
ui: {
footer: {
items: ['workspace', 'git-branch', 'model-name'],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
@@ -893,18 +781,14 @@ describe('<Footer />', () => {
describe('fallback mode display', () => {
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
},
);
await waitUntilReady();
});
// Footer should show the effective model (Flash), not the config model (Pro)
expect(lastFrame()).toContain('gemini-2.5-flash');
@@ -913,18 +797,14 @@ describe('<Footer />', () => {
});
it('should display Pro model when NOT in fallback mode', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
},
);
await waitUntilReady();
});
expect(lastFrame()).toContain('gemini-2.5-pro');
unmount();
@@ -30,19 +30,17 @@ describe('<FooterConfigDialog />', () => {
{ settings },
);
await renderResult.waitUntilReady();
expect(renderResult.lastFrame()).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
});
it('toggles an item when enter is pressed', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
const { lastFrame, stdin } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
act(() => {
stdin.write('\r'); // Enter to toggle
});
@@ -62,12 +60,11 @@ describe('<FooterConfigDialog />', () => {
it('reorders items with arrow keys', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
const { lastFrame, stdin } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Initial order: workspace, git-branch, ...
const output = lastFrame();
const cwdIdx = output.indexOf('] workspace');
@@ -93,12 +90,11 @@ describe('<FooterConfigDialog />', () => {
it('closes on Esc', async () => {
const settings = createMockSettings();
const { stdin, waitUntilReady } = await renderWithProviders(
const { stdin } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
act(() => {
stdin.write('\x1b'); // Esc
});
@@ -115,9 +111,8 @@ describe('<FooterConfigDialog />', () => {
{ settings },
);
const { lastFrame, stdin, waitUntilReady } = renderResult;
const { lastFrame, stdin } = renderResult;
await waitUntilReady();
expect(lastFrame()).toContain('~/project/path');
// Move focus down to 'code-changes' (which has colored elements)
@@ -148,13 +143,11 @@ describe('<FooterConfigDialog />', () => {
it('shows an empty preview when all items are deselected', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
const { lastFrame, stdin } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Default items are the first 5. We toggle them off.
for (let i = 0; i < 5; i++) {
act(() => {
@@ -178,11 +171,10 @@ describe('<FooterConfigDialog />', () => {
it('moves item correctly after trying to move up at the top', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
const { lastFrame, stdin } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Default initial items in mock settings are 'git-branch', 'workspace', ...
await waitFor(() => {
@@ -222,8 +214,7 @@ describe('<FooterConfigDialog />', () => {
{ settings },
);
const { lastFrame, stdin, waitUntilReady } = renderResult;
await waitUntilReady();
const { lastFrame, stdin } = renderResult;
// By default labels are on
expect(lastFrame()).toContain('workspace (/directory)');
@@ -41,10 +41,7 @@ describe('GeminiRespondingSpinner', () => {
it('renders spinner when responding', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame()).toContain('GeminiSpinner');
unmount();
});
@@ -52,30 +49,23 @@ describe('GeminiRespondingSpinner', () => {
it('renders screen reader text when responding and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
unmount();
});
it('renders nothing when not responding and no non-responding display', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders non-responding display when provided', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Waiting...');
unmount();
});
@@ -83,10 +73,9 @@ describe('GeminiRespondingSpinner', () => {
it('renders screen reader loading text when non-responding display provided and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
await waitUntilReady();
expect(lastFrame()).toContain(SCREEN_READER_LOADING);
unmount();
});

Some files were not shown because too many files have changed in this diff Show More