Compare commits

..

69 Commits

Author SHA1 Message Date
gemini-cli-robot de13a2ccb4 chore(release): v0.19.3 2025-12-05 07:34:15 +00:00
gemini-cli-robot ee6b01f9c7 fix(patch): cherry-pick 934b309 to release/v0.19.2-pr-14566 to patch version v0.19.2 and create version 0.19.3 (#14571)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-12-05 07:03:08 +00:00
gemini-cli-robot a35d001fde chore(release): v0.19.2 2025-12-05 04:51:01 +00:00
gemini-cli-robot 95f9032bf4 fix(patch): cherry-pick d284fa6 to release/v0.19.1-pr-14545 [CONFLICTS] (#14558)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-12-04 20:33:55 -08:00
gemini-cli-robot 578c497417 chore(release): v0.19.1 2025-12-03 02:10:47 +00:00
gemini-cli-robot 6169ef04ba fix(patch): cherry-pick bde8b78 to release/v0.19.0-pr-14301 to patch version v0.19.0 and create version 0.19.1 (#14418)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
Co-authored-by: jacob314 <jacob314@gmail.com>
2025-12-02 17:54:15 -08:00
gemini-cli-robot 933e0dc8ec chore(release): v0.19.0 2025-12-02 23:18:39 +00:00
gemini-cli-robot 90a5dc3da5 chore(release): v0.19.0-preview.1 2025-12-02 22:26:26 +00:00
gemini-cli-robot 403d29c63c fix(patch): cherry-pick 576fda1 to release/v0.19.0-preview.0-pr-14099 [CONFLICTS] (#14402)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2025-12-02 13:41:09 -08:00
gemini-cli-robot e1ea24806e chore(release): v0.19.0-preview.0 2025-11-26 02:44:48 +00:00
Billy Biggs d2a6cff4df Update dependency for modelcontextprotocol/sdk to 1.23.0 (#13827) 2025-11-26 01:23:13 +00:00
Aarushi Shah acf5ed595f Add Databricks auth support and custom header option to gemini cli (#11893)
Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
2025-11-26 00:22:47 +00:00
Pato Beltran 03845198ce Update Code Wiki README badge (#13768)
Co-authored-by: Pato Beltran <patobeltran@google.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2025-11-25 23:52:41 +00:00
Sehoon Shon d8a3d08f8e fallback to flash for TerminalQuota errors (#13791) 2025-11-25 21:17:22 +00:00
Daniel Alejandro Coll Tejeda 6f9118dca6 Fix TypeError: "URL.parse is not a function" for Node.js < v22 (#13698) 2025-11-25 20:18:49 +00:00
bl-ue d0b6701fba docs(sessions): add documentation for chat recording and session management (#13667) 2025-11-25 18:55:29 +00:00
bl-ue 94c3eecb99 feat(sessions): add /resume slash command to open the session browser (#13621) 2025-11-25 18:54:09 +00:00
bl-ue 098e5c281c fix(resume): allow passing a prompt via stdin while resuming using --resume (#13520) 2025-11-25 17:53:17 +00:00
Maksym f2c52f777c fix(cli): allow non-GitHub SCP-styled URLs for extension installation (#13800)
Co-authored-by: Maksym Kursin <maksym.kursin@dkatalis.com>
2025-11-25 16:57:53 +00:00
Scott Wu 2b41263aa7 fix: Add $schema property to settings.schema.json (#12763)
Co-authored-by: cornmander <shikhman@google.com>
2025-11-25 04:48:47 +00:00
Adam Weidman 404a4468d8 chore: remove console.error (#13779) 2025-11-25 04:24:51 +00:00
gemini-cli-robot 7f67c7f952 chore/release: bump version to 0.19.0-nightly.20251125.f6d97d448 (#13782) 2025-11-25 02:42:52 +00:00
Adam Weidman c8540b5744 feat(config): add isModelAvailabilityServiceEnabled setting (#13777) 2025-11-25 01:26:47 +00:00
Edilmo Palencia f6d97d4488 feat(hooks): Hook System Orchestration (#9102) 2025-11-24 23:24:18 +00:00
Adam Weidman 87712a0a7c feat(model-availability): introduce ModelPolicy and PolicyCatalog (#13751) 2025-11-24 23:08:10 +00:00
joshualitt ba0e053ffc bug(core): Add default chat compression config. (#13766) 2025-11-24 23:02:00 +00:00
joshualitt d14779b264 feat(core): Land bool for alternate system prompt. (#13764) 2025-11-24 22:34:44 +00:00
Edilmo Palencia 5411f4a667 feat(hooks): Hook Agent Lifecycle Integration (#9105) 2025-11-24 22:31:48 +00:00
Edilmo Palencia 2034098780 feat(hooks): Hook Event Handling (#9097) 2025-11-24 21:51:39 +00:00
grMLEqomlkkU5Eeinz4brIrOVCUCkJuN d53a5c4fb5 fix: (some minor improvements to configs and getPackageJson return behaviour) (#12510)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2025-11-24 21:20:23 +00:00
Edilmo Palencia f6ee025c46 feat(hooks): Hook Telemetry Infrastructure (#9082) 2025-11-24 21:13:24 +00:00
joshualitt e50bf6adad feat(core): Migrate chatCompressionService to model configs. (#12863) 2025-11-24 20:24:45 +00:00
Adib234 c21b6899e1 Add session subtask in /stats command (#13750) 2025-11-24 19:01:41 +00:00
Megha Bansal 95693e265e Improve code coverage for cli package (#13724) 2025-11-24 17:41:46 +00:00
JAYADITYA 569c6f1dd0 feat :rephrasing the extension logging messages to run the explore command when there are no extensions installed (#13740) 2025-11-24 15:54:15 +00:00
Kimihiko Kitase 7350399a50 fix(core): Fix context window overflow warning for PDF files (#13548) 2025-11-24 07:46:03 +00:00
gemini-cli-robot c2a741ee36 chore/release: bump version to 0.19.0-nightly.20251124.e177314a4 (#13713) 2025-11-24 02:16:24 +00:00
gemini-cli-robot e177314a4a chore/release: bump version to 0.19.0-nightly.20251123.dadd606c0 (#13675) 2025-11-23 01:53:21 +00:00
gemini-cli-robot dadd606c0d chore/release: bump version to 0.19.0-nightly.20251122.42c2e1b21 (#13637) 2025-11-22 07:46:20 +00:00
Jacob Richman b3fcddde11 Update ink version to 6.4.6 (#13631) 2025-11-22 03:20:28 +00:00
Megha Bansal e205a468d9 Improve test coverage for cli/src/ui/components (#13598) 2025-11-22 02:47:29 +00:00
christine betts bdf80ea7c0 fix(cli): Prevent stdout/stderr patching for extension commands (#13600)
Co-authored-by: jacob314 <jacob314@gmail.com>
2025-11-22 02:08:06 +00:00
Jacob Richman 5e218a5630 Turn off alternate buffer mode by default. (#13623) 2025-11-22 01:30:38 +00:00
joshualitt 8c36b10682 feat(core): Add BaseLlmClient.generateContent. (#13591) 2025-11-22 01:27:57 +00:00
joshualitt 42c2e1b217 feat(core): Add support for custom aliases for model configs. (#13546) 2025-11-22 00:13:10 +00:00
Tommaso Sciortino 1e715d1e5c Restore bracketed paste mode after external editor exit (#13606) 2025-11-21 21:20:37 +00:00
owenofbrien 9f9a2fa844 Moved client id logging into createBasicLogEvent (#13607) 2025-11-21 21:20:15 +00:00
owenofbrien 64eb14ab11 Fix exp id logging (#13430) 2025-11-21 20:44:50 +00:00
joshualitt 0713c86dec feat(docs): Ensure multiline JS objects are rendered properly. (#13535) 2025-11-21 17:59:34 +00:00
Adib234 99c5bf2e97 Remove useModelRouter experimental flag (#13593) 2025-11-21 17:54:17 +00:00
joshualitt fe67ef63c1 bug(core): Ensure we use thinking budget on fallback to 2.5 (#13596) 2025-11-21 17:43:20 +00:00
JAYADITYA 19d4384f16 docs: Update uninstall command to reflect multiple extension support (#13582) 2025-11-21 17:38:59 +00:00
Jack Wotherspoon d351f07758 feat: custom loading phrase when interactive shell requires input (#12535) 2025-11-21 17:19:34 +00:00
noahacgn d72f35c2e7 docs: fix grammar typo "a MCP" to "an MCP" (#13595) 2025-11-21 16:59:45 +00:00
Adam Weidman aeffa2a460 feat(core): add modelAvailabilityService for managing and tracking model health (#13426) 2025-11-21 16:54:16 +00:00
Jacob Richman 030a5ace97 Fix multiple bugs with auth flow including using the implemented but unused restart support. (#13565) 2025-11-21 16:31:47 +00:00
bl-ue b97661553f feat(ui): build interactive session browser component (#13351) 2025-11-21 16:16:56 +00:00
Megha Bansal 3370644ffe Improved code coverage for cli/src/zed-integration (#13570) 2025-11-21 15:38:33 +00:00
fancivez 0f0b463a2f docs: fix typos in source code and documentation (#13577) 2025-11-21 14:59:42 +00:00
Megha Bansal 8d082a904d Improve code coverage for cli/src/ui/privacy package (#13493) 2025-11-21 06:34:30 +00:00
Megha Bansal 61582678bf Optimize and improve test coverage for cli/src/config (#13485) 2025-11-21 04:57:59 +00:00
Pyry Takala 613b8a4527 fix(core): correct bash @P prompt transformation detection (#13544) 2025-11-21 00:34:47 +00:00
Sandy Tao 5982abeffb fix(ui): Correct mouse click cursor positioning for wide characters (#13537) 2025-11-20 23:56:31 +00:00
Vish (Ishaya) Abrams 78b10dccf1 Skip pre-commit hooks for shadow repo (#13331) (#13488)
Co-authored-by: Vishvananda Abrams <vabrams@google.com>
2025-11-20 22:50:36 +00:00
gemini-cli-robot 85bc25a765 chore(release): bump version to 0.19.0-nightly.20251120.8e531dc02 (#13540) 2025-11-20 22:43:11 +00:00
Jacob Richman fec0eba07e move stdio (#13528) 2025-11-20 22:16:46 +00:00
Tommaso Sciortino f92e79eba0 update docs for http proxy (#13538) 2025-11-20 22:13:59 +00:00
Adib234 3e50be1658 Update persistence state to track counts of messages instead of times banner has been displayed (#13428) 2025-11-20 21:57:06 +00:00
cornmander 9937fb2220 Use lenient MCP output schema validator (#13521) 2025-11-20 21:51:25 +00:00
325 changed files with 21937 additions and 4192 deletions
+54
View File
@@ -0,0 +1,54 @@
description="Injects context of all relevant cli files"
prompt = """
The source code contains the content of absolutely every source code file in
packages/cli. In addition to the source code, the following test files are
included as they are test files that conform to the project's testing standards:
`packages/cli/src/ui/components/InputPrompt.test.tsx` and `packages/cli/src/ui/App.test.tsx`.
You should very rarely need to read any other files from packages/cli to resolve
prompts.
!{find packages/cli -path packages/cli/dist -prune -o -type f \\( -name "*.ts" -o -name "*.tsx" \\) ! -name "*.test.ts" ! -name "*.test.tsx" ! -name "*.d.ts" -exec echo "--- {} ---" \\; -exec cat {} \\; && echo "--- packages/cli/src/ui/components/InputPrompt.test.tsx ---" && cat packages/cli/src/ui/components/InputPrompt.test.tsx && echo "--- packages/cli/src/ui/App.test.tsx ---" && cat packages/cli/src/ui/App.test.tsx}
**Pay extremely close attention to these files.** They define the project's
core architecture, component patterns, and testing standards.
In addition to the code context, you MUST strictly adhere to the following frontend-specific development guidelines while adding code to packages/cli.
## Testing Standards
* **Async Testing**: ALWAYS use `waitFor` from `packages/cli/src/test-utils/async.ts` instead of `vi.waitFor` to prevent flakiness and `act` warnings. NEVER use fixed waits (e.g., `await delay(100)`).
* **State Changes**: Wrap all blocks that change component state in `act`.
* **Snapshots**: Use `toMatchSnapshot` to verify rendering.
* **Rendering**: Use `render` or `renderWithProviders` from `packages/cli/src/test-utils/render.tsx` instead of `ink-testing-library` directly.
* **Mocking**:
* Reuse existing mocks/fakes where possible.
* **Parameterized Tests**: Use parameterized tests with explicit types to reduce duplication and ensure type safety.
* Avoid mocking the file system, os, or child_process if at all possible; if you have to mock the Mock critical dependencies (`fs`, `os`, `child_process`) do so ONLY at the top of the file.
## React & Ink Architecture
* **Keyboard Handling**: You MUST use `useKeyPress.ts` from Gemini CLI for keyboard handling, NOT the standard ink library. This is critical for supporting slow terminals and multiple events per frame.
* Handle multiple events gracefully (often requires a reducer pattern, see `text-buffer.ts`).
* **State Management**:
* NEVER trigger side effects from within the body of a `setState` callback. Use a reducer or `useRef` if necessary.
* Initialize state explicitly (e.g., use `undefined` rather than `true` if unknown).
* **Performance**:
* Avoid synchronous file I/O in components.
* Do not introduce excessive property drilling; leverage or extend existing providers.
## Configuration & Settings
* **Settings vs Args**: Use settings for user-configurable options; do not add new CLI arguments.
* **Schema**: Add new settings to `packages/cli/src/config/settingsSchema.ts`.
* **Documentation**:
* If `showInDialog: true`, document in `docs/get-started/configuration.md`.
* Ensure `requiresRestart` is correctly set.
## Keyboard Shortcuts
* **Registration**: Define new shortcuts in `packages/cli/src/config/keyBindings.ts`.
* **Documentation**: Document all new shortcuts in `docs/cli/keyboard-shortcuts.md`.
* **Compatibility**: Avoid function keys and common VSCode shortcuts. Be cautious with `Meta` key (Mac-limited support).
## General
* **Logging**: Use `debugLogger` for errors. NEVER leave `console.log/warn/error` in the code.
* **TypeScript**: Avoid the non-null assertion operator (`!`).
{{args}}.
"""
+57
View File
@@ -0,0 +1,57 @@
description="Injects context of all relevant cli files"
prompt = """
The following output contains the complete
source code of the gemini cli library (`packages/cli`), and {packages/core} and representative test files
(`packages/cli/src/ui/components/InputPrompt.test.tsx` and `packages/cli/src/ui/App.test.tsx`) that best conform to the project's testing standards.
**Pay extremely close attention to these files.** They define the project's
core architecture, component patterns, and testing standards.
The source code contains the content of absolutely every source code file in
packages/cli and packages/core. In addition to the source code, the following test files are
included as they are test files that conform to the project's testing standards:
`packages/cli/src/ui/components/InputPrompt.test.tsx` and `packages/cli/src/ui/App.test.tsx`.
You should very rarely need to read any other files from packages/cli to resolve
prompts.
!{find packages/cli packages/core \\( -path packages/cli/dist -o -path packages/core/dist -o -name node_modules \\) -prune -o -type f \\( -name "*.ts" -o -name "*.tsx" \\) ! -name "*.test.ts" ! -name "*.test.tsx" ! -name "*.d.ts" -exec echo "--- {} ---" \\; -exec cat {} \\; && echo "--- packages/cli/src/ui/components/InputPrompt.test.tsx ---" && cat packages/cli/src/ui/components/InputPrompt.test.tsx && echo "--- packages/cli/src/ui/App.test.tsx ---" && cat packages/cli/src/ui/App.test.tsx}
In addition to the code context, you MUST strictly adhere to the following frontend-specific development guidelines when writing code in packages/cli.
## Testing Standards
* **Async Testing**: ALWAYS use `waitFor` from `packages/cli/src/test-utils/async.ts` instead of `vi.waitFor` to prevent flakiness and `act` warnings. NEVER use fixed waits (e.g., `await delay(100)`).
* **State Changes**: Wrap all blocks that change component state in `act`.
* **Snapshots**: Use `toMatchSnapshot` to verify rendering.
* **Rendering**: Use `render` or `renderWithProviders` from `packages/cli/src/test-utils/render.tsx` instead of `ink-testing-library` directly.
* **Mocking**:
* Reuse existing mocks/fakes where possible.
* **Parameterized Tests**: Use parameterized tests with explicit types to reduce duplication and ensure type safety.
* Avoid mocking the file system, os, or child_process if at all possible; if you have to mock the Mock critical dependencies (`fs`, `os`, `child_process`) do so ONLY at the top of the file.
## React & Ink Architecture
* **Keyboard Handling**: You MUST use `useKeyPress.ts` from Gemini CLI for keyboard handling, NOT the standard ink library. This is critical for supporting slow terminals and multiple events per frame.
* Handle multiple events gracefully (often requires a reducer pattern, see `text-buffer.ts`).
* **State Management**:
* NEVER trigger side effects from within the body of a `setState` callback. Use a reducer or `useRef` if necessary.
* Initialize state explicitly (e.g., use `undefined` rather than `true` if unknown).
* **Performance**:
* Avoid synchronous file I/O in components.
* Do not introduce excessive property drilling; leverage or extend existing providers.
## Configuration & Settings
* **Settings vs Args**: Use settings for user-configurable options; do not add new CLI arguments.
* **Schema**: Add new settings to `packages/cli/src/config/settingsSchema.ts`.
* **Documentation**:
* If `showInDialog: true`, document in `docs/get-started/configuration.md`.
* Ensure `requiresRestart` is correctly set.
## Keyboard Shortcuts
* **Registration**: Define new shortcuts in `packages/cli/src/config/keyBindings.ts`.
* **Documentation**: Document all new shortcuts in `docs/cli/keyboard-shortcuts.md`.
* **Compatibility**: Avoid function keys and common VSCode shortcuts. Be cautious with `Meta` key (Mac-limited support).
## General
* **Logging**: Use `debugLogger` for errors. NEVER leave `console.log/warn/error` in the code.
* **TypeScript**: Avoid the non-null assertion operator (`!`).
{{args}}.
"""
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
- name: 'Link Checker'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with:
args: '--verbose ./**/*.md'
args: '--verbose --accept 200,503 ./**/*.md'
fail: true
test_linux:
name: 'Test (Linux)'
+1 -1
View File
@@ -20,4 +20,4 @@ jobs:
id: 'lychee'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with:
args: '--verbose --no-progress ./**/*.md'
args: '--verbose --no-progress --accept 200,503 ./**/*.md'
+1 -1
View File
@@ -4,7 +4,7 @@
[![Gemini CLI E2E](https://github.com/google-gemini/gemini-cli/actions/workflows/e2e.yml/badge.svg)](https://github.com/google-gemini/gemini-cli/actions/workflows/e2e.yml)
[![Version](https://img.shields.io/npm/v/@google/gemini-cli)](https://www.npmjs.com/package/@google/gemini-cli)
[![License](https://img.shields.io/github/license/google-gemini/gemini-cli)](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE)
<a href="https://codewiki.google/github.com/google-gemini/gemini-cli"><img src="https://www.gstatic.com/_/boq-sdlc-agents-ui/_/r/Mvosg4klCA4.svg" alt="Ask Code Wiki" height="20"></a>
[![View Code Wiki](https://www.gstatic.com/_/boq-sdlc-agents-ui/_/r/YUi5dj2UWvE.svg)](https://codewiki.google/github.com/google-gemini/gemini-cli)
![Gemini CLI Screenshot](./docs/assets/gemini-screenshot.png)
+15
View File
@@ -164,6 +164,21 @@ Slash commands provide meta-level control over the CLI itself.
- **Note:** Only available if checkpointing is configured via
[settings](../get-started/configuration.md). See
[Checkpointing documentation](../cli/checkpointing.md) for more details.
- **`/resume`**
- **Description:** Browse and resume previous conversation sessions. Opens an
interactive session browser where you can search, filter, and select from
automatically saved conversations.
- **Features:**
- **Session Browser:** Interactive interface showing all saved sessions with
timestamps, message counts, and first user message for context
- **Search:** Use `/` to search through conversation content across all
sessions
- **Sorting:** Sort sessions by date or message count
- **Management:** Delete unwanted sessions directly from the browser
- **Resume:** Select any session to resume and continue the conversation
- **Note:** All conversations are automatically saved as you chat - no manual
saving required. See [Session Management](../cli/session-management.md) for
complete details.
- [**`/settings`**](./settings.md)
- **Description:** Open the settings editor to view and modify Gemini CLI
+15 -3
View File
@@ -464,6 +464,18 @@ the `excludedProjectEnvVars` setting in your `settings.json` file.
- Specifies the default Gemini model to use.
- Overrides the hardcoded default
- Example: `export GEMINI_MODEL="gemini-2.5-flash"`
- **`GEMINI_CLI_CUSTOM_HEADERS`**:
- Adds extra HTTP headers to Gemini API and Code Assist requests.
- Accepts a comma-separated list of `Name: value` pairs.
- Example:
`export GEMINI_CLI_CUSTOM_HEADERS="X-My-Header: foo, X-Trace-ID: abc123"`.
- **`GEMINI_API_KEY_AUTH_MECHANISM`**:
- Specifies how the API key should be sent for authentication when using
`AuthType.USE_GEMINI` or `AuthType.USE_VERTEX_AI`.
- Valid values are `x-goog-api-key` (default) or `bearer`.
- If set to `bearer`, the API key will be sent in the
`Authorization: Bearer <key>` header.
- Example: `export GEMINI_API_KEY_AUTH_MECHANISM="bearer"`
- **`GOOGLE_API_KEY`**:
- Your Google Cloud API key.
- Required for using Vertex AI in express mode.
@@ -494,6 +506,9 @@ the `excludedProjectEnvVars` setting in your `settings.json` file.
- **`GEMINI_SANDBOX`**:
- Alternative to the `sandbox` setting in `settings.json`.
- Accepts `true`, `false`, `docker`, `podman`, or a custom command string.
- **`HTTP_PROXY` / `HTTPS_PROXY`**:
- Specifies the proxy server to use for outgoing HTTP/HTTPS requests.
- Example: `export HTTPS_PROXY="http://proxy.example.com:8080"`
- **`SEATBELT_PROFILE`** (macOS specific):
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
- `permissive-open`: (Default) Restricts writes to the project folder (and a
@@ -581,9 +596,6 @@ for that specific session.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--proxy`**:
- Sets the proxy for the CLI.
- Example: `--proxy http://localhost:7890`.
- **`--include-directories <dir1,dir2,...>`**:
- Includes additional directories in the workspace for multi-directory
support.
+1 -16
View File
@@ -25,18 +25,6 @@ Here's how it works:
`packages/cli/src/zed-integration/zedIntegration.ts` which checks if
`isInFallbackMode()` is true.
## Configuration
Model routing is controlled by the `useModelRouter` setting in your
`settings.json` file.
- **`"experimental.useModelRouter": true` (Default):** Enables the model
routing/fallback feature.
- **`"experimental.useModelRouter": false`:** Disables the model
routing/fallback feature. If a model fails, the CLI will not attempt to switch
to a fallback model.
### Model Selection Precedence
The model used by Gemini CLI is determined by the following order of precedence:
@@ -50,7 +38,4 @@ The model used by Gemini CLI is determined by the following order of precedence:
model specified in the `model.name` property of your `settings.json` file
will be used.
4. **Default Model:** If none of the above are set, the default model will be
used. The default model is determined by the `useModelRouter` setting:
- If `useModelRouter` is `true`, the default model is `"auto"`.
- If `useModelRouter` is `false`, the default model is the standard Gemini
model.
used. The default model is `auto`
+158
View File
@@ -0,0 +1,158 @@
# Session Management
Gemini CLI includes robust session management features that automatically save
your conversation history. This allows you to interrupt your work and resume
exactly where you left off, review past interactions, and manage your
conversation history effectively.
## Automatic Saving
Every time you interact with Gemini CLI, your session is automatically saved.
This happens in the background without any manual intervention.
- **What is saved:** The complete conversation history, including:
- Your prompts and the model's responses.
- All tool executions (inputs and outputs).
- Token usage statistics (input/output/cached, etc.).
- Assistant thoughts/reasoning summaries (when available).
- **Location:** Sessions are stored in `~/.gemini/tmp/<project_hash>/chats/`.
- **Scope:** Sessions are project-specific. Switching directories to a different
project will switch to that project's session history.
## Resuming Sessions
You can resume a previous session to continue the conversation with all prior
context restored.
### From the Command Line
When starting the CLI, you can use the `--resume` (or `-r`) flag:
- **Resume latest:**
```bash
gemini --resume
```
This immediately loads the most recent session.
- **Resume by index:** First, list available sessions (see
[Listing Sessions](#listing-sessions)), then use the index number:
```bash
gemini --resume 1
```
- **Resume by ID:** You can also provide the full session UUID:
```bash
gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890
```
### From the Interactive Interface
While the CLI is running, you can use the `/resume` slash command to open the
**Session Browser**:
```text
/resume
```
This opens an interactive interface where you can:
- **Browse:** Scroll through a list of your past sessions.
- **Preview:** See details like the session date, message count, and the first
user prompt.
- **Search:** Press `/` to enter search mode, then type to filter sessions by ID
or content.
- **Select:** Press `Enter` to resume the selected session.
## Managing Sessions
### Listing Sessions
To see a list of all available sessions for the current project from the command
line:
```bash
gemini --list-sessions
```
Output example:
```text
Available sessions for this project (3):
1. Fix bug in auth (2 days ago) [a1b2c3d4]
2. Refactor database schema (5 hours ago) [e5f67890]
3. Update documentation (Just now) [abcd1234]
```
### Deleting Sessions
You can remove old or unwanted sessions to free up space or declutter your
history.
**From the Command Line:** Use the `--delete-session` flag with an index or ID:
```bash
gemini --delete-session 2
```
**From the Session Browser:**
1. Open the browser with `/resume`.
2. Navigate to the session you want to remove.
3. Press `x`.
## Configuration
You can configure how Gemini CLI manages your session history in your
`settings.json` file.
### Session Retention
To prevent your history from growing indefinitely, you can enable automatic
cleanup policies.
```json
{
"general": {
"sessionRetention": {
"enabled": true,
"maxAge": "30d", // Keep sessions for 30 days
"maxCount": 50 // Keep the 50 most recent sessions
}
}
}
```
- **`enabled`**: (boolean) Master switch for session cleanup. Default is
`false`.
- **`maxAge`**: (string) Duration to keep sessions (e.g., "24h", "7d", "4w").
Sessions older than this will be deleted.
- **`maxCount`**: (number) Maximum number of sessions to retain. The oldest
sessions exceeding this count will be deleted.
- **`minRetention`**: (string) Minimum retention period (safety limit). Defaults
to `"1d"`; sessions newer than this period are never deleted by automatic
cleanup.
### Session Limits
You can also limit the length of individual sessions to prevent context windows
from becoming too large and expensive.
```json
{
"model": {
"maxSessionTurns": 100
}
}
```
- **`maxSessionTurns`**: (number) The maximum number of turns (user + model
exchanges) allowed in a single session. Set to `-1` for unlimited (default).
**Behavior when limit is reached:**
- **Interactive Mode:** The CLI shows an informational message and stops
sending requests to the model. You must manually start a new session.
- **Non-Interactive Mode:** The CLI exits with an error.
+4 -5
View File
@@ -106,8 +106,7 @@ they appear in the UI.
### Experimental
| UI Label | Setting | Description | Default |
| ----------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------- | ------- |
| Use Model Router | `experimental.useModelRouter` | Enable model routing to route requests to the best model based on complexity. | `true` |
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
| UI Label | Setting | Description | Default |
| ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | ------- |
| Enable Codebase Investigator | `experimental.codebaseInvestigatorSettings.enabled` | Enable the Codebase Investigator agent. | `true` |
| Codebase Investigator Max Num Turns | `experimental.codebaseInvestigatorSettings.maxNumTurns` | Maximum number of turns for the Codebase Investigator agent. | `10` |
+1 -1
View File
@@ -8,7 +8,7 @@ This page contains tutorials for interacting with Gemini CLI.
> and understand the tools it provides. Your use of third-party servers is at
> your own risk.
This tutorial demonstrates how to set up a MCP server, using the
This tutorial demonstrates how to set up an MCP server, using the
[GitHub MCP server](https://github.com/github/github-mcp-server) as an example.
The GitHub MCP server provides tools for interacting with GitHub repositories,
such as creating issues and commenting on pull requests.
+3 -2
View File
@@ -54,10 +54,11 @@ gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release]
### Uninstalling an extension
To uninstall, run `gemini extensions uninstall <name>`:
To uninstall one or more extensions, run
`gemini extensions uninstall <name...>`:
```
gemini extensions uninstall gemini-cli-security
gemini extensions uninstall gemini-cli-security gemini-cli-another-extension
```
### Disabling an extension
+1 -1
View File
@@ -256,7 +256,7 @@ To avoid setting environment variables in every terminal session, you can:
## Non-interactive mode / headless environments
Non-interative mode / headless environments will use your existing
Non-interactive mode / headless environments will use your existing
authentication method, if an existing authentication credential is cached.
If you have not already logged in with an authentication credential (such as a
+3 -3
View File
@@ -611,6 +611,9 @@ the `excludedProjectEnvVars` setting in your `settings.json` file.
- **`GEMINI_SANDBOX`**:
- Alternative to the `sandbox` setting in `settings.json`.
- Accepts `true`, `false`, `docker`, `podman`, or a custom command string.
- **`HTTP_PROXY` / `HTTPS_PROXY`**:
- Specifies the proxy server to use for outgoing HTTP/HTTPS requests.
- Example: `export HTTPS_PROXY="http://proxy.example.com:8080"`
- **`SEATBELT_PROFILE`** (macOS specific):
- Switches the Seatbelt (`sandbox-exec`) profile on macOS.
- `permissive-open`: (Default) Restricts writes to the project folder (and a
@@ -699,9 +702,6 @@ for that specific session.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--proxy`**:
- Sets the proxy for the CLI.
- Example: `--proxy http://localhost:7890`.
- **`--include-directories <dir1,dir2,...>`**:
- Includes additional directories in the workspace for multi-directory
support.
+239 -8
View File
@@ -240,7 +240,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.useAlternateBuffer`** (boolean):
- **Description:** Use an alternate screen buffer for the UI, preserving shell
history.
- **Default:** `true`
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.incrementalRendering`** (boolean):
@@ -317,7 +317,212 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Named presets for model configs. Can be used in place of a
model name and can inherit from other aliases using an `extends` property.
- **Default:**
`{"base":{"modelConfig":{"generateContentConfig":{"temperature":0,"topP":1}}},"chat-base":{"extends":"base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"includeThoughts":true},"temperature":1,"topP":0.95,"topK":64}}},"chat-base-2.5":{"extends":"chat-base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"thinkingBudget":8192}}}},"chat-base-3":{"extends":"chat-base","modelConfig":{"generateContentConfig":{"thinkingConfig":{"thinkingLevel":"HIGH"}}}},"gemini-3-pro-preview":{"extends":"chat-base-3","modelConfig":{"model":"gemini-3-pro-preview"}},"gemini-2.5-pro":{"extends":"chat-base-2.5","modelConfig":{"model":"gemini-2.5-pro"}},"gemini-2.5-flash":{"extends":"chat-base-2.5","modelConfig":{"model":"gemini-2.5-flash"}},"gemini-2.5-flash-lite":{"extends":"chat-base-2.5","modelConfig":{"model":"gemini-2.5-flash-lite"}},"gemini-2.5-flash-base":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash"}},"classifier":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":1024,"thinkingConfig":{"thinkingBudget":512}}}},"prompt-completion":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"temperature":0.3,"maxOutputTokens":16000,"thinkingConfig":{"thinkingBudget":0}}}},"edit-corrector":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"thinkingConfig":{"thinkingBudget":0}}}},"summarizer-default":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"summarizer-shell":{"extends":"base","modelConfig":{"model":"gemini-2.5-flash-lite","generateContentConfig":{"maxOutputTokens":2000}}},"web-search":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"googleSearch":{}}]}}},"web-fetch":{"extends":"gemini-2.5-flash-base","modelConfig":{"generateContentConfig":{"tools":[{"urlContext":{}}]}}},"web-fetch-fallback":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"loop-detection":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"loop-detection-double-check":{"extends":"base","modelConfig":{"model":"gemini-2.5-pro"}},"llm-edit-fixer":{"extends":"gemini-2.5-flash-base","modelConfig":{}},"next-speaker-checker":{"extends":"gemini-2.5-flash-base","modelConfig":{}}}`
```json
{
"base": {
"modelConfig": {
"generateContentConfig": {
"temperature": 0,
"topP": 1
}
}
},
"chat-base": {
"extends": "base",
"modelConfig": {
"generateContentConfig": {
"thinkingConfig": {
"includeThoughts": true
},
"temperature": 1,
"topP": 0.95,
"topK": 64
}
}
},
"chat-base-2.5": {
"extends": "chat-base",
"modelConfig": {
"generateContentConfig": {
"thinkingConfig": {
"thinkingBudget": 8192
}
}
}
},
"chat-base-3": {
"extends": "chat-base",
"modelConfig": {
"generateContentConfig": {
"thinkingConfig": {
"thinkingLevel": "HIGH"
}
}
}
},
"gemini-3-pro-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3-pro-preview"
}
},
"gemini-2.5-pro": {
"extends": "chat-base-2.5",
"modelConfig": {
"model": "gemini-2.5-pro"
}
},
"gemini-2.5-flash": {
"extends": "chat-base-2.5",
"modelConfig": {
"model": "gemini-2.5-flash"
}
},
"gemini-2.5-flash-lite": {
"extends": "chat-base-2.5",
"modelConfig": {
"model": "gemini-2.5-flash-lite"
}
},
"gemini-2.5-flash-base": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash"
}
},
"classifier": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 1024,
"thinkingConfig": {
"thinkingBudget": 512
}
}
}
},
"prompt-completion": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"temperature": 0.3,
"maxOutputTokens": 16000,
"thinkingConfig": {
"thinkingBudget": 0
}
}
}
},
"edit-corrector": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"thinkingConfig": {
"thinkingBudget": 0
}
}
}
},
"summarizer-default": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
}
},
"summarizer-shell": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
}
},
"web-search": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {
"generateContentConfig": {
"tools": [
{
"googleSearch": {}
}
]
}
}
},
"web-fetch": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {
"generateContentConfig": {
"tools": [
{
"urlContext": {}
}
]
}
}
},
"web-fetch-fallback": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {}
},
"loop-detection": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {}
},
"loop-detection-double-check": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-pro"
}
},
"llm-edit-fixer": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {}
},
"next-speaker-checker": {
"extends": "gemini-2.5-flash-base",
"modelConfig": {}
},
"chat-compression-3-pro": {
"modelConfig": {
"model": "gemini-3-pro-preview"
}
},
"chat-compression-2.5-pro": {
"modelConfig": {
"model": "gemini-2.5-pro"
}
},
"chat-compression-2.5-flash": {
"modelConfig": {
"model": "gemini-2.5-flash"
}
},
"chat-compression-2.5-flash-lite": {
"modelConfig": {
"model": "gemini-2.5-flash-lite"
}
},
"chat-compression-default": {
"modelConfig": {
"model": "gemini-2.5-pro"
}
}
}
```
- **`modelConfigs.customAliases`** (object):
- **Description:** Custom named presets for model configs. These are merged
with (and override) the built-in aliases.
- **Default:** `{}`
- **`modelConfigs.overrides`** (array):
- **Description:** Apply specific configuration overrides based on matches,
@@ -545,7 +750,11 @@ their corresponding top-level category object in your `settings.json` file.
- **`advanced.excludedEnvVars`** (array):
- **Description:** Environment variables to exclude from project context.
- **Default:** `["DEBUG","DEBUG_MODE"]`
- **Default:**
```json
["DEBUG", "DEBUG_MODE"]
```
- **`advanced.bugCommand`** (object):
- **Description:** Configuration for the bug report command.
@@ -563,10 +772,9 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.useModelRouter`** (boolean):
- **Description:** Enable model routing to route requests to the best model
based on complexity.
- **Default:** `true`
- **`experimental.isModelAvailabilityServiceEnabled`** (boolean):
- **Description:** Enable model routing using new availability service.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.codebaseInvestigatorSettings.enabled`** (boolean):
@@ -674,7 +882,12 @@ of v0.3.0:
{
"general": {
"vimMode": true,
"preferredEditor": "code"
"preferredEditor": "code",
"sessionRetention": {
"enabled": true,
"maxAge": "30d",
"maxCount": 100
}
},
"ui": {
"theme": "GitHub",
@@ -910,6 +1123,24 @@ for that specific session.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--resume [session_id]`** (**`-r [session_id]`**):
- Resume a previous chat session. Use "latest" for the most recent session,
provide a session index number, or provide a full session UUID.
- If no session_id is provided, defaults to "latest".
- Example: `gemini --resume 5` or `gemini --resume latest` or
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
- See [Session Management](../cli/session-management.md) for more details.
- **`--list-sessions`**:
- List all available chat sessions for the current project and exit.
- Shows session indices, dates, message counts, and preview of first user
message.
- Example: `gemini --list-sessions`
- **`--delete-session <identifier>`**:
- Delete a specific chat session by its index number or full session UUID.
- Use `--list-sessions` first to see available sessions, their indices, and
UUIDs.
- Example: `gemini --delete-session 3` or
`gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890`
- **`--include-directories <dir1,dir2,...>`**:
- Includes additional directories in the workspace for multi-directory
support.
+4
View File
@@ -84,6 +84,10 @@
"label": "Sandbox",
"slug": "docs/cli/sandbox"
},
{
"label": "Session Management",
"slug": "docs/cli/session-management"
},
{
"label": "Settings",
"slug": "docs/cli/settings"
-1
View File
@@ -29,7 +29,6 @@ export default tseslint.config(
// Global ignores
ignores: [
'node_modules/*',
'.integration-tests/**',
'eslint.config.js',
'packages/**/dist/**',
'bundle/**',
+3
View File
@@ -321,6 +321,9 @@ export class TestRig {
selectedType: 'gemini-api-key',
},
},
ui: {
useAlternateBuffer: true,
},
model: DEFAULT_GEMINI_MODEL,
sandbox:
env['GEMINI_SANDBOX'] !== 'false' ? env['GEMINI_SANDBOX'] : false,
+29 -25
View File
@@ -1,17 +1,17 @@
{
"name": "@google/gemini-cli",
"version": "0.18.0-preview.0",
"version": "0.19.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.18.0-preview.0",
"version": "0.19.3",
"workspaces": [
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.5",
"ink": "npm:@jrichman/ink@6.4.6",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
},
@@ -2047,9 +2047,9 @@
]
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.22.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz",
"integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==",
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.23.0.tgz",
"integrity": "sha512-MCGd4K9aZKvuSqdoBkdMvZNcYXCkZRYVs/Gh92mdV5IHbctX9H9uIvd4X93+9g8tBbXv08sxc/QHXTzf8y65bA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.17.1",
@@ -2063,18 +2063,22 @@
"express-rate-limit": "^7.5.0",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.24.1"
"zod": "^3.25 || ^4.0",
"zod-to-json-schema": "^3.25.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@cfworker/json-schema": "^4.1.1"
"@cfworker/json-schema": "^4.1.1",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@cfworker/json-schema": {
"optional": true
},
"zod": {
"optional": false
}
}
},
@@ -10311,9 +10315,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.4.5",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.5.tgz",
"integrity": "sha512-mIDkZqtJbedL9XDOoqoJt3S8aGQVqEJYnCnSeLlYzkpUWCsSWC0hW40yJ0DLH86lcl8k5R5lv/9C2i/3746nWw==",
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.6.tgz",
"integrity": "sha512-QHl6l1cl3zPCaRMzt9TUbTX6Q5SzvkGEZDDad0DmSf5SPmT1/90k6pGPejEvDCJprkitwObXpPaTWGHItqsy4g==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
@@ -17600,17 +17604,17 @@
}
},
"node_modules/zod-to-json-schema": {
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz",
"integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==",
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.24.1"
"zod": "^3.25 || ^4"
}
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.18.0-preview.0",
"version": "0.19.3",
"dependencies": {
"@a2a-js/sdk": "^0.3.2",
"@google-cloud/storage": "^7.16.0",
@@ -17900,12 +17904,12 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.18.0-preview.0",
"version": "0.19.3",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.15.1",
"@modelcontextprotocol/sdk": "^1.23.0",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"clipboardy": "^5.0.0",
@@ -17917,7 +17921,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.5",
"ink": "npm:@jrichman/ink@6.4.6",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -18001,7 +18005,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.18.0-preview.0",
"version": "0.19.3",
"dependencies": {
"@google-cloud/logging": "^11.2.1",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
@@ -18009,7 +18013,7 @@
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@joshua.litt/get-ripgrep": "^0.0.3",
"@modelcontextprotocol/sdk": "^1.11.0",
"@modelcontextprotocol/sdk": "^1.23.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.203.0",
@@ -18145,7 +18149,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.18.0-preview.0",
"version": "0.19.3",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -18156,10 +18160,10 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.18.0-preview.0",
"version": "0.19.3",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
"@modelcontextprotocol/sdk": "^1.23.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"zod": "^3.25.76"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.18.0-preview.0",
"version": "0.19.3",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.18.0-preview.0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.19.3"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -62,7 +62,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.5",
"ink": "npm:@jrichman/ink@6.4.6",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -119,7 +119,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.5",
"ink": "npm:@jrichman/ink@6.4.6",
"latest-version": "^9.0.0",
"simple-git": "^3.28.0"
},
@@ -153,7 +153,7 @@ syntax = "proto3";
import "google/protobuf/struct.proto";
// ToolCall is the central message represeting a tool's execution lifecycle.
// ToolCall is the central message representing a tool's execution lifecycle.
// The entire object is sent from the agent to client on every update.
message ToolCall {
// A unique identifier, assigned by the agent
@@ -197,7 +197,7 @@ enum ToolCallStatus {
CANCELLED = 5;
}
// ToolOuput represents the final, successful, output of a tool
// ToolOutput represents the final, successful, output of a tool
message ToolOutput {
oneof result {
string text = 1;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.18.0-preview.0",
"version": "0.19.3",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+1 -1
View File
@@ -460,7 +460,7 @@ export class Task {
): Message {
const messageParts: Part[] = [];
// Create a serializable version of the ToolCall (pick necesssary
// Create a serializable version of the ToolCall (pick necessary
// properties/avoid methods causing circular reference errors)
const serializableToolCall: Partial<ToolCall> = this._pickFields(
tc,
@@ -14,7 +14,9 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
GeminiClient,
HookSystem,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
import { expect, vi } from 'vitest';
@@ -54,8 +56,13 @@ export function createMockConfig(
getMessageBus: vi.fn(),
getPolicyEngine: vi.fn(),
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
getEnableHooks: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Config;
mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus());
mockConfig.getHookSystem = vi
.fn()
.mockReturnValue(new HookSystem(mockConfig));
mockConfig.getGeminiClient = vi
.fn()
+9 -7
View File
@@ -6,25 +6,27 @@
* SPDX-License-Identifier: Apache-2.0
*/
import './src/gemini.js';
import { main } from './src/gemini.js';
import { debugLogger, FatalError } from '@google/gemini-cli-core';
import { FatalError, writeToStderr } from '@google/gemini-cli-core';
import { runExitCleanup } from './src/utils/cleanup.js';
// --- Global Entry Point ---
main().catch((error) => {
main().catch(async (error) => {
await runExitCleanup();
if (error instanceof FatalError) {
let errorMessage = error.message;
if (!process.env['NO_COLOR']) {
errorMessage = `\x1b[31m${errorMessage}\x1b[0m`;
}
debugLogger.error(errorMessage);
writeToStderr(errorMessage + '\n');
process.exit(error.exitCode);
}
debugLogger.error('An unexpected critical error occurred:');
writeToStderr('An unexpected critical error occurred:');
if (error instanceof Error) {
debugLogger.error(error.stack);
writeToStderr(error.stack + '\n');
} else {
debugLogger.error(String(error));
writeToStderr(String(error) + '\n');
}
process.exit(1);
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.18.0-preview.0",
"version": "0.19.3",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,13 +25,13 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.18.0-preview.0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.19.3"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.15.1",
"@modelcontextprotocol/sdk": "^1.23.0",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"clipboardy": "^5.0.0",
@@ -43,7 +43,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.5",
"ink": "npm:@jrichman/ink@6.4.6",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -1,5 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
"
`;
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
"
`;
exports[`runNonInteractive > should emit appropriate events for streaming JSON output 1`] = `
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Stream test"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Thinking...","delta":true}
{"type":"tool_use","timestamp":"<TIMESTAMP>","tool_name":"testTool","tool_id":"tool-1","parameters":{"arg1":"value1"}}
{"type":"tool_result","timestamp":"<TIMESTAMP>","tool_id":"tool-1","status":"success","output":"Tool executed successfully"}
{"type":"message","timestamp":"<TIMESTAMP>","role":"assistant","content":"Final answer","delta":true}
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"duration_ms":<DURATION>,"tool_calls":0}}
"
`;
exports[`runNonInteractive > should write a single newline between sequential text outputs from the model 1`] = `
"Use mock tool
Use mock tool again
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { extensionsCommand } from './extensions.js';
// Mock subcommands
vi.mock('./extensions/install.js', () => ({
installCommand: { command: 'install' },
}));
vi.mock('./extensions/uninstall.js', () => ({
uninstallCommand: { command: 'uninstall' },
}));
vi.mock('./extensions/list.js', () => ({ listCommand: { command: 'list' } }));
vi.mock('./extensions/update.js', () => ({
updateCommand: { command: 'update' },
}));
vi.mock('./extensions/disable.js', () => ({
disableCommand: { command: 'disable' },
}));
vi.mock('./extensions/enable.js', () => ({
enableCommand: { command: 'enable' },
}));
vi.mock('./extensions/link.js', () => ({ linkCommand: { command: 'link' } }));
vi.mock('./extensions/new.js', () => ({ newCommand: { command: 'new' } }));
vi.mock('./extensions/validate.js', () => ({
validateCommand: { command: 'validate' },
}));
// Mock gemini.js
vi.mock('../gemini.js', () => ({
initializeOutputListenersAndFlush: vi.fn(),
}));
describe('extensionsCommand', () => {
it('should have correct command and aliases', () => {
expect(extensionsCommand.command).toBe('extensions <command>');
expect(extensionsCommand.aliases).toEqual(['extension']);
expect(extensionsCommand.describe).toBe('Manage Gemini CLI extensions.');
});
it('should register all subcommands in builder', () => {
const mockYargs = {
middleware: vi.fn().mockReturnThis(),
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
version: vi.fn().mockReturnThis(),
};
// @ts-expect-error - Mocking yargs
extensionsCommand.builder(mockYargs);
expect(mockYargs.middleware).toHaveBeenCalled();
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'install' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'uninstall' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'list' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'update' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'disable' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'enable' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'link' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'new' });
expect(mockYargs.command).toHaveBeenCalledWith({ command: 'validate' });
expect(mockYargs.demandCommand).toHaveBeenCalledWith(1, expect.any(String));
expect(mockYargs.version).toHaveBeenCalledWith(false);
});
it('should have a handler that does nothing', () => {
// @ts-expect-error - Handler doesn't take arguments in this case
expect(extensionsCommand.handler()).toBeUndefined();
});
});
+2
View File
@@ -14,6 +14,7 @@ import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
import { validateCommand } from './extensions/validate.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
@@ -21,6 +22,7 @@ export const extensionsCommand: CommandModule = {
describe: 'Manage Gemini CLI extensions.',
builder: (yargs) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(installCommand)
.command(uninstallCommand)
.command(listCommand)
@@ -56,6 +56,9 @@ vi.mock('../../config/extensions/consent.js', () => ({
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions disable command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
@@ -11,6 +11,7 @@ import { debugLogger } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface DisableArgs {
name: string;
@@ -81,5 +82,6 @@ export const disableCommand: CommandModule = {
name: argv['name'] as string,
scope: argv['scope'] as string,
});
await exitCli();
},
};
@@ -58,6 +58,9 @@ vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../config/extensions/consent.js');
vi.mock('../../config/extensions/extensionSettings.js');
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions enable command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
@@ -14,6 +14,7 @@ import {
getErrorMessage,
} from '@google/gemini-cli-core';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface EnableArgs {
name: string;
@@ -86,5 +87,6 @@ export const enableCommand: CommandModule = {
name: argv['name'] as string,
scope: argv['scope'] as string,
});
await exitCli();
},
};
@@ -12,7 +12,7 @@
"@types/node": "^20.11.25"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"@modelcontextprotocol/sdk": "^1.23.0",
"zod": "^3.22.4"
}
}
@@ -48,6 +48,10 @@ vi.mock('node:fs/promises', () => ({
},
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions install command', () => {
it('should fail if no source is provided', () => {
const validationParser = yargs([]).command(installCommand).fail(false);
@@ -18,6 +18,7 @@ import {
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface InstallArgs {
source: string;
@@ -130,5 +131,6 @@ export const installCommand: CommandModule = {
allowPreRelease: argv['pre-release'] as boolean | undefined,
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
},
};
@@ -52,6 +52,9 @@ vi.mock('../../config/extensions/consent.js', () => ({
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions link command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
@@ -15,6 +15,7 @@ import { requestConsentNonInteractive } from '../../config/extensions/consent.js
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface InstallArgs {
path: string;
@@ -60,5 +61,6 @@ export const linkCommand: CommandModule = {
await handleLink({
path: argv['path'] as string,
});
await exitCli();
},
};
@@ -44,6 +44,9 @@ vi.mock('../../config/extensions/consent.js', () => ({
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions list command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
@@ -11,6 +11,7 @@ import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
export async function handleList() {
try {
@@ -45,5 +46,6 @@ export const listCommand: CommandModule = {
builder: (yargs) => yargs,
handler: async () => {
await handleList();
await exitCli();
},
};
@@ -11,6 +11,9 @@ import * as fsPromises from 'node:fs/promises';
import path from 'node:path';
vi.mock('node:fs/promises');
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
const mockedFs = vi.mocked(fsPromises);
@@ -9,6 +9,7 @@ import { join, dirname, basename } from 'node:path';
import type { CommandModule } from 'yargs';
import { fileURLToPath } from 'node:url';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
interface NewArgs {
path: string;
@@ -100,5 +101,6 @@ export const newCommand: CommandModule = {
path: args['path'] as string,
template: args['template'] as string | undefined,
});
await exitCli();
},
};
@@ -75,6 +75,9 @@ vi.mock('../../config/extensions/consent.js', () => ({
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions uninstall command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
@@ -11,6 +11,7 @@ import { requestConsentNonInteractive } from '../../config/extensions/consent.js
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface UninstallArgs {
names: string[]; // can be extension names or source URLs.
@@ -72,5 +73,6 @@ export const uninstallCommand: CommandModule = {
await handleUninstall({
names: argv['names'] as string[],
});
await exitCli();
},
};
@@ -56,6 +56,9 @@ vi.mock('../../config/extensions/consent.js', () => ({
vi.mock('../../config/extensions/extensionSettings.js', () => ({
promptForSetting: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions update command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
@@ -19,6 +19,7 @@ import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
interface UpdateArgs {
name?: string;
@@ -144,5 +145,6 @@ export const updateCommand: CommandModule = {
name: argv['name'] as string | undefined,
all: argv['all'] as boolean | undefined,
});
await exitCli();
},
};
@@ -13,6 +13,10 @@ import path from 'node:path';
import * as os from 'node:os';
import { debugLogger } from '@google/gemini-cli-core';
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('extensions validate command', () => {
it('should fail if no path is provided', () => {
const validationParser = yargs([]).command(validateCommand).fail(false);
@@ -15,6 +15,7 @@ import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { loadSettings } from '../../config/settings.js';
import { exitCli } from '../utils.js';
interface ValidateArgs {
path: string;
@@ -101,5 +102,6 @@ export const validateCommand: CommandModule = {
await handleValidate({
path: args['path'] as string,
});
await exitCli();
},
};
+1
View File
@@ -56,6 +56,7 @@ describe('mcp command', () => {
command: vi.fn().mockReturnThis(),
demandCommand: vi.fn().mockReturnThis(),
version: vi.fn().mockReturnThis(),
middleware: vi.fn().mockReturnThis(),
};
(mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv);
+2
View File
@@ -9,12 +9,14 @@ import type { CommandModule, Argv } from 'yargs';
import { addCommand } from './mcp/add.js';
import { removeCommand } from './mcp/remove.js';
import { listCommand } from './mcp/list.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
export const mcpCommand: CommandModule = {
command: 'mcp',
describe: 'Manage MCP servers',
builder: (yargs: Argv) =>
yargs
.middleware(() => initializeOutputListenersAndFlush())
.command(addCommand)
.command(removeCommand)
.command(listCommand)
@@ -10,6 +10,10 @@ import { addCommand } from './add.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
+2
View File
@@ -8,6 +8,7 @@
import type { CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { debugLogger, type MCPServerConfig } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
async function addMcpServer(
name: string,
@@ -230,5 +231,6 @@ export const addCommand: CommandModule = {
excludeTools: argv['excludeTools'] as string[] | undefined,
},
);
await exitCli();
},
};
@@ -44,6 +44,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
vi.mock('@modelcontextprotocol/sdk/client/index.js');
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
const mockedGetUserExtensionsDir =
ExtensionStorage.getUserExtensionsDir as Mock;
const mockedLoadSettings = loadSettings as Mock;
+2
View File
@@ -17,6 +17,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m';
@@ -145,5 +146,6 @@ export const listCommand: CommandModule = {
describe: 'List all configured MCP servers',
handler: async () => {
await listMcpServers();
await exitCli();
},
};
@@ -26,6 +26,10 @@ vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('mcp remove command', () => {
describe('unit tests with mocks', () => {
let parser: Argv;
+2
View File
@@ -8,6 +8,7 @@
import type { CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
async function removeMcpServer(
name: string,
@@ -57,5 +58,6 @@ export const removeCommand: CommandModule = {
await removeMcpServer(argv['name'] as string, {
scope: argv['scope'] as string,
});
await exitCli();
},
};
+41
View File
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { exitCli } from './utils.js';
import { runExitCleanup } from '../utils/cleanup.js';
vi.mock('../utils/cleanup.js', () => ({
runExitCleanup: vi.fn(),
}));
describe('utils', () => {
const originalProcessExit = process.exit;
beforeEach(() => {
// @ts-expect-error - Mocking process.exit
process.exit = vi.fn();
});
afterEach(() => {
process.exit = originalProcessExit;
vi.clearAllMocks();
});
describe('exitCli', () => {
it('should call runExitCleanup and process.exit with default exit code 0', async () => {
await exitCli();
expect(runExitCleanup).toHaveBeenCalled();
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should call runExitCleanup and process.exit with specified exit code', async () => {
await exitCli(1);
expect(runExitCleanup).toHaveBeenCalled();
expect(process.exit).toHaveBeenCalledWith(1);
});
});
});
+12
View File
@@ -0,0 +1,12 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { runExitCleanup } from '../utils/cleanup.js';
export async function exitCli(exitCode = 0) {
await runExitCleanup();
process.exit(exitCode);
}
+68 -49
View File
@@ -5,7 +5,7 @@
*/
import { AuthType } from '@google/gemini-cli-core';
import { vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { validateAuthMethod } from './auth.js';
vi.mock('./settings.js', () => ({
@@ -17,7 +17,6 @@ vi.mock('./settings.js', () => ({
describe('validateAuthMethod', () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv('GEMINI_API_KEY', undefined);
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
@@ -28,53 +27,73 @@ describe('validateAuthMethod', () => {
vi.unstubAllEnvs();
});
it('should return null for LOGIN_WITH_GOOGLE', () => {
expect(validateAuthMethod(AuthType.LOGIN_WITH_GOOGLE)).toBeNull();
});
it('should return null for COMPUTE_ADC', () => {
expect(validateAuthMethod(AuthType.COMPUTE_ADC)).toBeNull();
});
describe('USE_GEMINI', () => {
it('should return null if GEMINI_API_KEY is set', () => {
vi.stubEnv('GEMINI_API_KEY', 'test-key');
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
});
it('should return an error message if GEMINI_API_KEY is not set', () => {
vi.stubEnv('GEMINI_API_KEY', undefined);
expect(validateAuthMethod(AuthType.USE_GEMINI)).toBeNull();
});
});
describe('USE_VERTEX_AI', () => {
it('should return null if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set', () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project');
vi.stubEnv('GOOGLE_CLOUD_LOCATION', 'test-location');
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return null if GOOGLE_API_KEY is set', () => {
vi.stubEnv('GOOGLE_API_KEY', 'test-api-key');
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should return an error message if no required environment variables are set', () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', undefined);
vi.stubEnv('GOOGLE_CLOUD_LOCATION', undefined);
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBe(
it.each([
{
description: 'should return null for LOGIN_WITH_GOOGLE',
authType: AuthType.LOGIN_WITH_GOOGLE,
envs: {},
expected: null,
},
{
description: 'should return null for COMPUTE_ADC',
authType: AuthType.COMPUTE_ADC,
envs: {},
expected: null,
},
{
description: 'should return null for USE_GEMINI if GEMINI_API_KEY is set',
authType: AuthType.USE_GEMINI,
envs: { GEMINI_API_KEY: 'test-key' },
expected: null,
},
{
description:
'should return an error message for USE_GEMINI if GEMINI_API_KEY is not set',
authType: AuthType.USE_GEMINI,
envs: {},
expected:
'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' +
'Update your environment and try again (no reload needed if using .env)!',
},
{
description:
'should return null for USE_VERTEX_AI if GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION are set',
authType: AuthType.USE_VERTEX_AI,
envs: {
GOOGLE_CLOUD_PROJECT: 'test-project',
GOOGLE_CLOUD_LOCATION: 'test-location',
},
expected: null,
},
{
description:
'should return null for USE_VERTEX_AI if GOOGLE_API_KEY is set',
authType: AuthType.USE_VERTEX_AI,
envs: { GOOGLE_API_KEY: 'test-api-key' },
expected: null,
},
{
description:
'should return an error message for USE_VERTEX_AI if no required environment variables are set',
authType: AuthType.USE_VERTEX_AI,
envs: {},
expected:
'When using Vertex AI, you must specify either:\n' +
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!',
);
});
});
it('should return an error message for an invalid auth method', () => {
expect(validateAuthMethod('invalid-method')).toBe(
'Invalid auth method selected.',
);
'• GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION environment variables.\n' +
'• GOOGLE_API_KEY environment variable (if using express mode).\n' +
'Update your environment and try again (no reload needed if using .env)!',
},
{
description: 'should return an error message for an invalid auth method',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
authType: 'invalid-method' as any,
envs: {},
expected: 'Invalid auth method selected.',
},
])('$description', ({ authType, envs, expected }) => {
for (const [key, value] of Object.entries(envs)) {
vi.stubEnv(key, value as string);
}
expect(validateAuthMethod(authType)).toBe(expected);
});
});
+6
View File
@@ -17,6 +17,12 @@ export function validateAuthMethod(authMethod: string): string | null {
}
if (authMethod === AuthType.USE_GEMINI) {
if (!process.env['GEMINI_API_KEY']) {
return (
'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' +
'Update your environment and try again (no reload needed if using .env)!'
);
}
return null;
}
@@ -4,7 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
@@ -64,106 +73,33 @@ describe('Configuration Integration Tests', () => {
}
});
describe('File Filtering Configuration', () => {
it('should load default file filtering settings', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
);
});
it('should load custom file filtering settings from configuration', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: false,
},
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
});
it('should merge user and workspace file filtering settings', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: true,
},
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
});
});
describe('Configuration Integration', () => {
it('should handle partial configuration objects gracefully', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: false,
},
};
const config = new Config(configParams);
// Specified settings should be applied
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
});
it('should handle empty configuration objects gracefully', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
describe('File Filtering and Configuration', () => {
it.each([
{
description:
'should load default file filtering settings when fileFiltering is missing',
fileFiltering: undefined,
expected: DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
},
{
description:
'should load custom file filtering settings from configuration',
fileFiltering: { respectGitIgnore: false },
expected: false,
},
{
description:
'should respect file filtering settings from configuration',
fileFiltering: { respectGitIgnore: true },
expected: true,
},
{
description:
'should handle empty fileFiltering object gracefully and use defaults',
fileFiltering: {},
};
const config = new Config(configParams);
// All settings should use defaults
expect(config.getFileFilteringRespectGitIgnore()).toBe(
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
);
});
it('should handle missing configuration sections gracefully', async () => {
expected: DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
},
])('$description', async ({ fileFiltering, expected }) => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
@@ -172,20 +108,26 @@ describe('Configuration Integration Tests', () => {
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
// Missing fileFiltering configuration
fileFiltering,
};
const config = new Config(configParams);
// All git-aware settings should use defaults
expect(config.getFileFilteringRespectGitIgnore()).toBe(
DEFAULT_FILE_FILTERING_OPTIONS.respectGitIgnore,
);
expect(config.getFileFilteringRespectGitIgnore()).toBe(expected);
});
});
describe('Real-world Configuration Scenarios', () => {
it('should handle a security-focused configuration', async () => {
it.each([
{
description: 'should handle a security-focused configuration',
respectGitIgnore: true,
},
{
description: 'should handle a CI/CD environment configuration',
respectGitIgnore: false,
},
])('$description', async ({ respectGitIgnore }) => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
@@ -195,32 +137,13 @@ describe('Configuration Integration Tests', () => {
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: true,
respectGitIgnore,
},
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(true);
});
it('should handle a CI/CD environment configuration', async () => {
const configParams: ConfigParameters = {
sessionId: 'test-session',
cwd: '/tmp',
model: 'test-model',
embeddingModel: 'test-embedding-model',
sandbox: undefined,
targetDir: tempDir,
debugMode: false,
fileFiltering: {
respectGitIgnore: false,
}, // CI might need to see all files
};
const config = new Config(configParams);
expect(config.getFileFilteringRespectGitIgnore()).toBe(false);
expect(config.getFileFilteringRespectGitIgnore()).toBe(respectGitIgnore);
});
});
@@ -252,139 +175,70 @@ describe('Configuration Integration Tests', () => {
parseArguments = parseArgs;
});
it('should parse --approval-mode=auto_edit correctly through the full argument parsing flow', async () => {
const originalArgv = process.argv;
try {
process.argv = [
it.each([
{
description: 'should parse --approval-mode=auto_edit correctly',
argv: [
'node',
'script.js',
'--approval-mode',
'auto_edit',
'-p',
'test',
];
const argv = await parseArguments({} as Settings);
// Verify that the argument was parsed correctly
expect(argv.approvalMode).toBe('auto_edit');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false);
],
expected: { approvalMode: 'auto_edit', prompt: 'test', yolo: false },
},
{
description: 'should parse --approval-mode=yolo correctly',
argv: ['node', 'script.js', '--approval-mode', 'yolo', '-p', 'test'],
expected: { approvalMode: 'yolo', prompt: 'test', yolo: false },
},
{
description: 'should parse --approval-mode=default correctly',
argv: ['node', 'script.js', '--approval-mode', 'default', '-p', 'test'],
expected: { approvalMode: 'default', prompt: 'test', yolo: false },
},
{
description: 'should parse legacy --yolo flag correctly',
argv: ['node', 'script.js', '--yolo', '-p', 'test'],
expected: { yolo: true, approvalMode: undefined, prompt: 'test' },
},
{
description: 'should handle no approval mode arguments',
argv: ['node', 'script.js', '-p', 'test'],
expected: { approvalMode: undefined, yolo: false, prompt: 'test' },
},
])('$description', async ({ argv, expected }) => {
const originalArgv = process.argv;
try {
process.argv = argv;
const parsedArgs = await parseArguments({} as Settings);
expect(parsedArgs.approvalMode).toBe(expected.approvalMode);
expect(parsedArgs.prompt).toBe(expected.prompt);
expect(parsedArgs.yolo).toBe(expected.yolo);
} finally {
process.argv = originalArgv;
}
});
it('should parse --approval-mode=yolo correctly through the full argument parsing flow', async () => {
it.each([
{
description: 'should reject invalid approval mode values',
argv: ['node', 'script.js', '--approval-mode', 'invalid_mode'],
},
{
description:
'should reject conflicting --yolo and --approval-mode flags',
argv: ['node', 'script.js', '--yolo', '--approval-mode', 'default'],
},
])('$description', async ({ argv }) => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--approval-mode',
'yolo',
'-p',
'test',
];
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBe('yolo');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false); // Should NOT be set when using --approval-mode
} finally {
process.argv = originalArgv;
}
});
it('should parse --approval-mode=default correctly through the full argument parsing flow', async () => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--approval-mode',
'default',
'-p',
'test',
];
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBe('default');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false);
} finally {
process.argv = originalArgv;
}
});
it('should parse legacy --yolo flag correctly', async () => {
const originalArgv = process.argv;
try {
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
const argv = await parseArguments({} as Settings);
expect(argv.yolo).toBe(true);
expect(argv.approvalMode).toBeUndefined(); // Should NOT be set when using --yolo
expect(argv.prompt).toBe('test');
} finally {
process.argv = originalArgv;
}
});
it('should reject invalid approval mode values during argument parsing', async () => {
const originalArgv = process.argv;
try {
process.argv = ['node', 'script.js', '--approval-mode', 'invalid_mode'];
// Should throw during argument parsing due to yargs validation
process.argv = argv;
await expect(parseArguments({} as Settings)).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
});
it('should reject conflicting --yolo and --approval-mode flags', async () => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--yolo',
'--approval-mode',
'default',
];
// Should throw during argument parsing due to conflict validation
await expect(parseArguments({} as Settings)).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
});
it('should handle backward compatibility with mixed scenarios', async () => {
const originalArgv = process.argv;
try {
// Test that no approval mode arguments defaults to no flags set
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBeUndefined();
expect(argv.yolo).toBe(false);
expect(argv.prompt).toBe('test');
} finally {
process.argv = originalArgv;
}
});
});
});
+249 -436
View File
@@ -9,8 +9,6 @@ import * as os from 'node:os';
import * as path from 'node:path';
import {
DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
OutputFormat,
SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
@@ -149,307 +147,264 @@ afterEach(() => {
});
describe('parseArguments', () => {
it('should throw an error when both --prompt and --prompt-interactive are used together', async () => {
process.argv = [
'node',
'script.js',
'--prompt',
'test prompt',
'--prompt-interactive',
'interactive prompt',
];
it.each([
{
description: 'long flags',
argv: [
'node',
'script.js',
'--prompt',
'test prompt',
'--prompt-interactive',
'interactive prompt',
],
},
{
description: 'short flags',
argv: [
'node',
'script.js',
'-p',
'test prompt',
'-i',
'interactive prompt',
],
},
])(
'should throw an error when using conflicting prompt flags ($description)',
async ({ argv }) => {
process.argv = argv;
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
},
);
it.each([
{
description: 'should allow --prompt without --prompt-interactive',
argv: ['node', 'script.js', '--prompt', 'test prompt'],
expected: { prompt: 'test prompt', promptInteractive: undefined },
},
{
description: 'should allow --prompt-interactive without --prompt',
argv: ['node', 'script.js', '--prompt-interactive', 'interactive prompt'],
expected: { prompt: undefined, promptInteractive: 'interactive prompt' },
},
{
description: 'should allow -i flag as alias for --prompt-interactive',
argv: ['node', 'script.js', '-i', 'interactive prompt'],
expected: { prompt: undefined, promptInteractive: 'interactive prompt' },
},
])('$description', async ({ argv, expected }) => {
process.argv = argv;
const parsedArgs = await parseArguments({} as Settings);
expect(parsedArgs.prompt).toBe(expected.prompt);
expect(parsedArgs.promptInteractive).toBe(expected.promptInteractive);
});
describe('positional arguments and @commands', () => {
it.each([
{
description:
'should convert positional query argument to prompt by default',
argv: ['node', 'script.js', 'Hi Gemini'],
expectedQuery: 'Hi Gemini',
expectedModel: undefined,
debug: false,
},
{
description:
'should map @path to prompt (one-shot) when it starts with @',
argv: ['node', 'script.js', '@path ./file.md'],
expectedQuery: '@path ./file.md',
expectedModel: undefined,
debug: false,
},
{
description:
'should map @path to prompt even when config flags are present',
argv: [
'node',
'script.js',
'@path',
'./file.md',
'--model',
'gemini-2.5-pro',
],
expectedQuery: '@path ./file.md',
expectedModel: 'gemini-2.5-pro',
debug: false,
},
{
description:
'maps unquoted positional @path + arg to prompt (one-shot)',
argv: ['node', 'script.js', '@path', './file.md'],
expectedQuery: '@path ./file.md',
expectedModel: undefined,
debug: false,
},
{
description:
'should handle multiple @path arguments in a single command (one-shot)',
argv: [
'node',
'script.js',
'@path',
'./file1.md',
'@path',
'./file2.md',
],
expectedQuery: '@path ./file1.md @path ./file2.md',
expectedModel: undefined,
debug: false,
},
{
description:
'should handle mixed quoted and unquoted @path arguments (one-shot)',
argv: [
'node',
'script.js',
'@path ./file1.md',
'@path',
'./file2.md',
'additional text',
],
expectedQuery: '@path ./file1.md @path ./file2.md additional text',
expectedModel: undefined,
debug: false,
},
{
description: 'should map @path to prompt with ambient flags (debug)',
argv: ['node', 'script.js', '@path', './file.md', '--debug'],
expectedQuery: '@path ./file.md',
expectedModel: undefined,
debug: true,
},
{
description: 'should map @include to prompt (one-shot)',
argv: ['node', 'script.js', '@include src/'],
expectedQuery: '@include src/',
expectedModel: undefined,
debug: false,
},
{
description: 'should map @search to prompt (one-shot)',
argv: ['node', 'script.js', '@search pattern'],
expectedQuery: '@search pattern',
expectedModel: undefined,
debug: false,
},
{
description: 'should map @web to prompt (one-shot)',
argv: ['node', 'script.js', '@web query'],
expectedQuery: '@web query',
expectedModel: undefined,
debug: false,
},
{
description: 'should map @git to prompt (one-shot)',
argv: ['node', 'script.js', '@git status'],
expectedQuery: '@git status',
expectedModel: undefined,
debug: false,
},
{
description: 'should handle @command with leading whitespace',
argv: ['node', 'script.js', ' @path ./file.md'],
expectedQuery: ' @path ./file.md',
expectedModel: undefined,
debug: false,
},
])(
'$description',
async ({ argv, expectedQuery, expectedModel, debug }) => {
process.argv = argv;
const parsedArgs = await parseArguments({} as Settings);
expect(parsedArgs.query).toBe(expectedQuery);
expect(parsedArgs.prompt).toBe(expectedQuery);
expect(parsedArgs.promptInteractive).toBeUndefined();
if (expectedModel) {
expect(parsedArgs.model).toBe(expectedModel);
}
if (debug) {
expect(parsedArgs.debug).toBe(true);
}
},
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
),
);
// yargs.showHelp() calls console.error
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should throw an error when using short flags -p and -i together', async () => {
process.argv = [
'node',
'script.js',
'-p',
'test prompt',
'-i',
'interactive prompt',
];
it.each([
{
description: 'long flags',
argv: ['node', 'script.js', '--yolo', '--approval-mode', 'default'],
},
{
description: 'short flags',
argv: ['node', 'script.js', '-y', '--approval-mode', 'yolo'],
},
])(
'should throw an error when using conflicting yolo/approval-mode flags ($description)',
async ({ argv }) => {
process.argv = argv;
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
),
);
expect(mockConsoleError).toHaveBeenCalled();
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
mockExit.mockRestore();
mockConsoleError.mockRestore();
},
);
it('should allow --prompt without --prompt-interactive', async () => {
process.argv = ['node', 'script.js', '--prompt', 'test prompt'];
const argv = await parseArguments({} as Settings);
expect(argv.prompt).toBe('test prompt');
expect(argv.promptInteractive).toBeUndefined();
});
it('should allow --prompt-interactive without --prompt', async () => {
process.argv = [
'node',
'script.js',
'--prompt-interactive',
'interactive prompt',
];
const argv = await parseArguments({} as Settings);
expect(argv.promptInteractive).toBe('interactive prompt');
expect(argv.prompt).toBeUndefined();
});
it('should allow -i flag as alias for --prompt-interactive', async () => {
process.argv = ['node', 'script.js', '-i', 'interactive prompt'];
const argv = await parseArguments({} as Settings);
expect(argv.promptInteractive).toBe('interactive prompt');
expect(argv.prompt).toBeUndefined();
});
it('should convert positional query argument to prompt by default', async () => {
process.argv = ['node', 'script.js', 'Hi Gemini'];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe('Hi Gemini');
expect(argv.prompt).toBe('Hi Gemini');
expect(argv.promptInteractive).toBeUndefined();
});
it('should map @path to prompt (one-shot) when it starts with @', async () => {
process.argv = ['node', 'script.js', '@path ./file.md'];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe('@path ./file.md');
expect(argv.prompt).toBe('@path ./file.md');
expect(argv.promptInteractive).toBeUndefined();
});
it('should map @path to prompt even when config flags are present', async () => {
// @path queries should now go to one-shot mode regardless of other flags
process.argv = [
'node',
'script.js',
'@path',
'./file.md',
'--model',
'gemini-2.5-pro',
];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe('@path ./file.md');
expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot
expect(argv.promptInteractive).toBeUndefined();
expect(argv.model).toBe('gemini-2.5-pro');
});
it('maps unquoted positional @path + arg to prompt (one-shot)', async () => {
// Simulate: gemini @path ./file.md
process.argv = ['node', 'script.js', '@path', './file.md'];
const argv = await parseArguments({} as Settings);
// After normalization, query is a single string
expect(argv.query).toBe('@path ./file.md');
// And it's mapped to one-shot prompt when no -p/-i flags are set
expect(argv.prompt).toBe('@path ./file.md');
expect(argv.promptInteractive).toBeUndefined();
});
it('should handle multiple @path arguments in a single command (one-shot)', async () => {
// Simulate: gemini @path ./file1.md @path ./file2.md
process.argv = [
'node',
'script.js',
'@path',
'./file1.md',
'@path',
'./file2.md',
];
const argv = await parseArguments({} as Settings);
// After normalization, all arguments are joined with spaces
expect(argv.query).toBe('@path ./file1.md @path ./file2.md');
// And it's mapped to one-shot prompt
expect(argv.prompt).toBe('@path ./file1.md @path ./file2.md');
expect(argv.promptInteractive).toBeUndefined();
});
it('should handle mixed quoted and unquoted @path arguments (one-shot)', async () => {
// Simulate: gemini "@path ./file1.md" @path ./file2.md "additional text"
process.argv = [
'node',
'script.js',
'@path ./file1.md',
'@path',
'./file2.md',
'additional text',
];
const argv = await parseArguments({} as Settings);
// After normalization, all arguments are joined with spaces
expect(argv.query).toBe(
'@path ./file1.md @path ./file2.md additional text',
);
// And it's mapped to one-shot prompt
expect(argv.prompt).toBe(
'@path ./file1.md @path ./file2.md additional text',
);
expect(argv.promptInteractive).toBeUndefined();
});
it('should map @path to prompt with ambient flags (debug)', async () => {
// Ambient flags like debug should NOT affect routing
process.argv = ['node', 'script.js', '@path', './file.md', '--debug'];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe('@path ./file.md');
expect(argv.prompt).toBe('@path ./file.md'); // Should map to one-shot
expect(argv.promptInteractive).toBeUndefined();
expect(argv.debug).toBe(true);
});
it('should map any @command to prompt (one-shot)', async () => {
// Test that all @commands now go to one-shot mode
const testCases = [
'@path ./file.md',
'@include src/',
'@search pattern',
'@web query',
'@git status',
];
for (const testQuery of testCases) {
process.argv = ['node', 'script.js', testQuery];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe(testQuery);
expect(argv.prompt).toBe(testQuery);
expect(argv.promptInteractive).toBeUndefined();
}
});
it('should handle @command with leading whitespace', async () => {
// Test that trim() + routing handles leading whitespace correctly
process.argv = ['node', 'script.js', ' @path ./file.md'];
const argv = await parseArguments({} as Settings);
expect(argv.query).toBe(' @path ./file.md');
expect(argv.prompt).toBe(' @path ./file.md');
expect(argv.promptInteractive).toBeUndefined();
});
it('should throw an error when both --yolo and --approval-mode are used together', async () => {
process.argv = [
'node',
'script.js',
'--yolo',
'--approval-mode',
'default',
];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should throw an error when using short flags -y and --approval-mode together', async () => {
process.argv = ['node', 'script.js', '-y', '--approval-mode', 'yolo'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
it('should allow --approval-mode without --yolo', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
const argv = await parseArguments({} as Settings);
expect(argv.approvalMode).toBe('auto_edit');
expect(argv.yolo).toBe(false);
});
it('should allow --yolo without --approval-mode', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments({} as Settings);
expect(argv.yolo).toBe(true);
expect(argv.approvalMode).toBeUndefined();
it.each([
{
description: 'should allow --approval-mode without --yolo',
argv: ['node', 'script.js', '--approval-mode', 'auto_edit'],
expected: { approvalMode: 'auto_edit', yolo: false },
},
{
description: 'should allow --yolo without --approval-mode',
argv: ['node', 'script.js', '--yolo'],
expected: { approvalMode: undefined, yolo: true },
},
])('$description', async ({ argv, expected }) => {
process.argv = argv;
const parsedArgs = await parseArguments({} as Settings);
expect(parsedArgs.approvalMode).toBe(expected.approvalMode);
expect(parsedArgs.yolo).toBe(expected.yolo);
});
it('should reject invalid --approval-mode values', async () => {
@@ -480,37 +435,15 @@ describe('parseArguments', () => {
debugErrorSpy.mockRestore();
});
it('should throw an error when resuming a session without prompt in non-interactive mode', async () => {
it('should allow resuming a session without prompt argument in non-interactive mode (expecting stdin)', async () => {
const originalIsTTY = process.stdin.isTTY;
process.stdin.isTTY = false;
process.argv = ['node', 'script.js', '--resume', 'session-id'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
try {
await expect(parseArguments({} as Settings)).rejects.toThrow(
'process.exit called',
);
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
),
);
expect(mockConsoleError).toHaveBeenCalled();
const argv = await parseArguments({} as Settings);
expect(argv.resume).toBe('session-id');
} finally {
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
process.stdin.isTTY = originalIsTTY;
}
});
@@ -1407,100 +1340,6 @@ describe('loadCliConfig model selection', () => {
});
});
describe('loadCliConfig model selection with model router', () => {
beforeEach(() => {
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.resetAllMocks();
});
it('should use auto model when useModelRouter is true and no model is provided', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
{
experimental: {
useModelRouter: true,
},
},
'test-session',
argv,
);
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL_AUTO);
});
it('should use default model when useModelRouter is false and no model is provided', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
{
experimental: {
useModelRouter: false,
},
},
'test-session',
argv,
);
expect(config.getModel()).toBe(DEFAULT_GEMINI_MODEL);
});
it('should prioritize argv over useModelRouter', async () => {
process.argv = ['node', 'script.js', '--model', 'gemini-from-argv'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
{
experimental: {
useModelRouter: true,
},
},
'test-session',
argv,
);
expect(config.getModel()).toBe('gemini-from-argv');
});
it('should prioritize settings over useModelRouter', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
{
experimental: {
useModelRouter: true,
},
model: {
name: 'gemini-from-settings',
},
},
'test-session',
argv,
);
expect(config.getModel()).toBe('gemini-from-settings');
});
it('should prioritize environment variable over useModelRouter', async () => {
process.argv = ['node', 'script.js'];
vi.stubEnv('GEMINI_MODEL', 'gemini-from-env');
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig(
{
experimental: {
useModelRouter: true,
},
},
'test-session',
argv,
);
expect(config.getModel()).toBe('gemini-from-env');
});
});
describe('loadCliConfig folderTrust', () => {
beforeEach(() => {
vi.resetAllMocks();
@@ -1676,32 +1515,6 @@ describe('loadCliConfig useRipgrep', () => {
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getUseRipgrep()).toBe(true);
});
describe('loadCliConfig useModelRouter', () => {
it('should be true by default when useModelRouter is not set in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getUseModelRouter()).toBe(true);
});
it('should be true when useModelRouter is set to true in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { experimental: { useModelRouter: true } };
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getUseModelRouter()).toBe(true);
});
it('should be false when useModelRouter is explicitly set to false in settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const settings: Settings = { experimental: { useModelRouter: false } };
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getUseModelRouter()).toBe(false);
});
});
});
describe('screenReader configuration', () => {
+3 -24
View File
@@ -15,7 +15,6 @@ import {
setGeminiMdFilename as setServerGeminiMdFilename,
getCurrentGeminiMdFilename,
ApprovalMode,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_FILE_FILTERING_OPTIONS,
@@ -263,11 +262,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
if (argv['prompt'] && argv['promptInteractive']) {
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
}
if (argv['resume'] && !argv['prompt'] && !process.stdin.isTTY) {
throw new Error(
'When resuming a session, you must provide a message via --prompt (-p) or stdin',
);
}
if (argv['yolo'] && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
@@ -313,19 +307,6 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
process.exit(0);
}
// If yargs handled --help/--version it will have exited; nothing to do here.
// Handle case where MCP subcommands are executed - they should exit the process
// and not return to main CLI logic
if (
result._.length > 0 &&
(result._[0] === 'mcp' || result._[0] === 'extensions')
) {
// MCP commands handle their own execution and process exit
await runExitCleanup();
process.exit(0);
}
// Normalize query args: handle both quoted "@path file" and unquoted @path file
const queryArg = (result as { query?: string | string[] | undefined }).query;
const q: string | undefined = Array.isArray(queryArg)
@@ -580,10 +561,7 @@ export async function loadCliConfig(
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const useModelRouter = settings.experimental?.useModelRouter ?? true;
const defaultModel = useModelRouter
? DEFAULT_GEMINI_MODEL_AUTO
: DEFAULT_GEMINI_MODEL;
const defaultModel = DEFAULT_GEMINI_MODEL_AUTO;
const resolvedModel: string =
argv.model ||
process.env['GEMINI_MODEL'] ||
@@ -653,6 +631,8 @@ export async function loadCliConfig(
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableModelAvailabilityService:
settings.experimental?.isModelAvailabilityServiceEnabled,
noBrowser: !!process.env['NO_BROWSER'],
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
@@ -674,7 +654,6 @@ export async function loadCliConfig(
output: {
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
useModelRouter,
enableMessageBusIntegration,
codebaseInvestigatorSettings:
settings.experimental?.codebaseInvestigatorSettings,
+128 -147
View File
@@ -4,7 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, type MockedFunction } from 'vitest';
import {
vi,
type MockedFunction,
describe,
it,
expect,
beforeEach,
afterEach,
afterAll,
} from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -14,7 +23,6 @@ import {
ExtensionDisableEvent,
ExtensionEnableEvent,
KeychainTokenStorage,
debugLogger,
} from '@google/gemini-cli-core';
import { loadSettings, SettingScope } from './settings.js';
import {
@@ -127,18 +135,18 @@ interface MockKeychainStorage {
isAvailable: ReturnType<typeof vi.fn>;
}
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<(consent: string) => Promise<boolean>>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let mockKeychainStorage: MockKeychainStorage;
let keychainData: Record<string, string>;
describe('extension tests', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<(consent: string) => Promise<boolean>>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let mockKeychainStorage: MockKeychainStorage;
let keychainData: Record<string, string>;
beforeEach(() => {
vi.clearAllMocks();
keychainData = {};
@@ -496,8 +504,8 @@ describe('extension tests', () => {
});
it('should skip extensions with invalid JSON and log a warning', async () => {
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Good extension
@@ -517,18 +525,18 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(debugErrorSpy).toHaveBeenCalledExactlyOnceWith(
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
),
);
debugErrorSpy.mockRestore();
consoleSpy.mockRestore();
});
it('should skip extensions with missing name and log a warning', async () => {
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
// Good extension
@@ -548,13 +556,13 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(debugErrorSpy).toHaveBeenCalledExactlyOnceWith(
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
);
debugErrorSpy.mockRestore();
consoleSpy.mockRestore();
});
it('should filter trust out of mcp servers', async () => {
@@ -589,48 +597,13 @@ describe('extension tests', () => {
const extension = extensions.find((e) => e.name === 'bad_name');
expect(extension).toBeUndefined();
// This test is a bit ambiguous, loadExtensions catches errors and logs them, returning null for that extension.
// The implementation in loadExtension uses debugLogger.error.
// However, this test previously expected console.error.
// Wait, if I change source code to use debugLogger, I should update this.
// But let's verify what loadExtension uses. It uses debugLogger.error (checked in previous turn).
// So I should spy on debugLogger.error here too.
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid extension name: "bad_name"'),
);
consoleSpy.mockRestore();
});
});
// ... (rest of the file)
it('should not load github extensions if blockGitExtensions is set', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://somehost.com/foo/bar',
},
});
const blockGitExtensionsSetting = {
security: {
blockGitExtensions: true,
},
};
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: blockGitExtensionsSetting,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension).toBeUndefined();
});
describe('id generation', () => {
it('should generate id from source for non-github git urls', async () => {
it('should not load github extensions if blockGitExtensions is set', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
@@ -640,103 +613,111 @@ describe('extension tests', () => {
source: 'http://somehost.com/foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('http://somehost.com/foo/bar'));
});
it('should generate id from owner/repo for github http urls', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'http://github.com/foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from owner/repo for github ssh urls', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'git',
source: 'git@github.com:foo/bar',
},
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
});
it('should generate id from source for github-release extension', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata: {
type: 'github-release',
source: 'https://github.com/foo/bar',
const blockGitExtensionsSetting = {
security: {
blockGitExtensions: true,
},
};
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: blockGitExtensionsSetting,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue('https://github.com/foo/bar'));
expect(extension).toBeUndefined();
});
it('should generate id from the original source for local extension', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-ext-name',
version: '1.0.0',
installMetadata: {
type: 'local',
source: '/some/path',
describe('id generation', () => {
it.each([
{
description: 'should generate id from source for non-github git urls',
installMetadata: {
type: 'git' as const,
source: 'http://somehost.com/foo/bar',
},
expectedIdSource: 'http://somehost.com/foo/bar',
},
{
description:
'should generate id from owner/repo for github http urls',
installMetadata: {
type: 'git' as const,
source: 'http://github.com/foo/bar',
},
expectedIdSource: 'https://github.com/foo/bar',
},
{
description: 'should generate id from owner/repo for github ssh urls',
installMetadata: {
type: 'git' as const,
source: 'git@github.com:foo/bar',
},
expectedIdSource: 'https://github.com/foo/bar',
},
{
description:
'should generate id from source for github-release extension',
installMetadata: {
type: 'github-release' as const,
source: 'https://github.com/foo/bar',
},
expectedIdSource: 'https://github.com/foo/bar',
},
{
description:
'should generate id from the original source for local extension',
installMetadata: {
type: 'local' as const,
source: '/some/path',
},
expectedIdSource: '/some/path',
},
])('$description', async ({ installMetadata, expectedIdSource }) => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'my-ext',
version: '1.0.0',
installMetadata,
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'my-ext');
expect(extension?.id).toBe(hashValue(expectedIdSource));
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'local-ext-name');
expect(extension?.id).toBe(hashValue('/some/path'));
});
it('should generate id from the original source for linked extensions', async () => {
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
const actualExtensionDir = createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
source: actualExtensionDir,
});
it('should generate id from the original source for linked extensions', async () => {
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
const actualExtensionDir = createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
source: actualExtensionDir,
const extension = extensionManager
.getExtensions()
.find((e) => e.name === 'link-ext-name');
expect(extension?.id).toBe(hashValue(actualExtensionDir));
});
const extension = extensionManager
.getExtensions()
.find((e) => e.name === 'link-ext-name');
expect(extension?.id).toBe(hashValue(actualExtensionDir));
});
it('should generate id from name for extension with no install metadata', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'no-meta-name',
version: '1.0.0',
});
it('should generate id from name for extension with no install metadata', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'no-meta-name',
version: '1.0.0',
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'no-meta-name');
expect(extension?.id).toBe(hashValue('no-meta-name'));
});
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === 'no-meta-name');
expect(extension?.id).toBe(hashValue('no-meta-name'));
});
});
@@ -1913,9 +1894,9 @@ This extension will run the following MCP servers:
);
});
});
function isEnabled(options: { name: string; enabledForPath: string }) {
const manager = new ExtensionEnablementManager();
return manager.isEnabled(options.name, options.enabledForPath);
}
});
function isEnabled(options: { name: string; enabledForPath: string }) {
const manager = new ExtensionEnablementManager();
return manager.isEnabled(options.name, options.enabledForPath);
}
@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
requestConsentNonInteractive,
requestConsentInteractive,
maybeRequestConsentOrFail,
INSTALL_WARNING_MESSAGE,
} from './consent.js';
import type { ConfirmationRequest } from '../../ui/types.js';
import type { ExtensionConfig } from '../extension.js';
import { debugLogger } from '@google/gemini-cli-core';
const mockReadline = vi.hoisted(() => ({
createInterface: vi.fn().mockReturnValue({
question: vi.fn(),
close: vi.fn(),
}),
}));
// Mocking readline for non-interactive prompts
vi.mock('node:readline', () => ({
default: mockReadline,
createInterface: mockReadline.createInterface,
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
log: vi.fn(),
},
};
});
describe('consent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('requestConsentNonInteractive', () => {
it.each([
{ input: 'y', expected: true },
{ input: 'Y', expected: true },
{ input: '', expected: true },
{ input: 'n', expected: false },
{ input: 'N', expected: false },
{ input: 'yes', expected: false },
])(
'should return $expected for input "$input"',
async ({ input, expected }) => {
const questionMock = vi.fn().mockImplementation((_, callback) => {
callback(input);
});
mockReadline.createInterface.mockReturnValue({
question: questionMock,
close: vi.fn(),
});
const consent = await requestConsentNonInteractive('Test consent');
expect(debugLogger.log).toHaveBeenCalledWith('Test consent');
expect(questionMock).toHaveBeenCalledWith(
'Do you want to continue? [Y/n]: ',
expect.any(Function),
);
expect(consent).toBe(expected);
},
);
});
describe('requestConsentInteractive', () => {
it.each([
{ confirmed: true, expected: true },
{ confirmed: false, expected: false },
])(
'should resolve with $expected when user confirms with $confirmed',
async ({ confirmed, expected }) => {
const addExtensionUpdateConfirmationRequest = vi
.fn()
.mockImplementation((request: ConfirmationRequest) => {
request.onConfirm(confirmed);
});
const consent = await requestConsentInteractive(
'Test consent',
addExtensionUpdateConfirmationRequest,
);
expect(addExtensionUpdateConfirmationRequest).toHaveBeenCalledWith({
prompt: 'Test consent\n\nDo you want to continue?',
onConfirm: expect.any(Function),
});
expect(consent).toBe(expected);
},
);
});
describe('maybeRequestConsentOrFail', () => {
const baseConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
};
it('should request consent if there is no previous config', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(baseConfig, requestConsent, undefined);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should not request consent if configs are identical', async () => {
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(baseConfig, requestConsent, baseConfig);
expect(requestConsent).not.toHaveBeenCalled();
});
it('should throw an error if consent is denied', async () => {
const requestConsent = vi.fn().mockResolvedValue(false);
await expect(
maybeRequestConsentOrFail(baseConfig, requestConsent, undefined),
).rejects.toThrow('Installation cancelled for "test-ext".');
});
describe('consent string generation', () => {
it('should generate a consent string with all fields', async () => {
const config: ExtensionConfig = {
...baseConfig,
mcpServers: {
server1: { command: 'npm', args: ['start'] },
server2: { httpUrl: 'https://remote.com' },
},
contextFileName: 'my-context.md',
excludeTools: ['tool1', 'tool2'],
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(config, requestConsent, undefined);
const expectedConsentString = [
'Installing extension "test-ext".',
INSTALL_WARNING_MESSAGE,
'This extension will run the following MCP servers:',
' * server1 (local): npm start',
' * server2 (remote): https://remote.com',
'This extension will append info to your gemini.md context using my-context.md',
'This extension will exclude the following core tools: tool1,tool2',
].join('\n');
expect(requestConsent).toHaveBeenCalledWith(expectedConsentString);
});
it('should request consent if mcpServers change', async () => {
const prevConfig: ExtensionConfig = { ...baseConfig };
const newConfig: ExtensionConfig = {
...baseConfig,
mcpServers: { server1: { command: 'npm', args: ['start'] } },
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should request consent if contextFileName changes', async () => {
const prevConfig: ExtensionConfig = { ...baseConfig };
const newConfig: ExtensionConfig = {
...baseConfig,
contextFileName: 'new-context.md',
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
it('should request consent if excludeTools changes', async () => {
const prevConfig: ExtensionConfig = { ...baseConfig };
const newConfig: ExtensionConfig = {
...baseConfig,
excludeTools: ['new-tool'],
};
const requestConsent = vi.fn().mockResolvedValue(true);
await maybeRequestConsentOrFail(newConfig, requestConsent, prevConfig);
expect(requestConsent).toHaveBeenCalledTimes(1);
});
});
});
});
@@ -6,30 +6,40 @@
import * as path from 'node:path';
import fs from 'node:fs';
import * as os from 'node:os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ExtensionEnablementManager, Override } from './extensionEnablement.js';
import { ExtensionStorage } from './storage.js';
vi.mock('./storage.js');
import {
coreEvents,
GEMINI_DIR,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
vi.mock('node:os', () => ({
homedir: vi.fn().mockReturnValue('/virtual-home'),
tmpdir: vi.fn().mockReturnValue('/virtual-tmp'),
}));
const inMemoryFs: { [key: string]: string } = {};
// Helper to create a temporary directory for testing
function createTestDir() {
const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
const dirPath = `/virtual-tmp/gemini-test-${Math.random().toString(36).substring(2, 15)}`;
inMemoryFs[dirPath] = ''; // Simulate directory existence
return {
path: dirPath,
cleanup: () => fs.rmSync(dirPath, { recursive: true, force: true }),
cleanup: () => {
for (const key in inMemoryFs) {
if (key.startsWith(dirPath)) {
delete inMemoryFs[key];
}
}
},
};
}
@@ -38,13 +48,55 @@ let manager: ExtensionEnablementManager;
describe('ExtensionEnablementManager', () => {
beforeEach(() => {
// Clear the in-memory file system before each test
for (const key in inMemoryFs) {
delete inMemoryFs[key];
}
expect(Object.keys(inMemoryFs).length).toBe(0); // Add this assertion
// Mock fs functions
vi.spyOn(fs, 'readFileSync').mockImplementation(
(path: fs.PathOrFileDescriptor) => {
const content = inMemoryFs[path.toString()];
if (content === undefined) {
const error = new Error(
`ENOENT: no such file or directory, open '${path}'`,
);
(error as NodeJS.ErrnoException).code = 'ENOENT';
throw error;
}
return content;
},
);
vi.spyOn(fs, 'writeFileSync').mockImplementation(
(
path: fs.PathOrFileDescriptor,
data: string | ArrayBufferView<ArrayBufferLike>,
) => {
inMemoryFs[path.toString()] = data.toString(); // Convert ArrayBufferView to string for inMemoryFs
},
);
vi.spyOn(fs, 'mkdirSync').mockImplementation(
(
_path: fs.PathLike,
_options?: fs.MakeDirectoryOptions | fs.Mode | null,
) => undefined,
);
vi.spyOn(fs, 'mkdtempSync').mockImplementation((prefix: string) => {
const virtualPath = `/virtual-tmp/${prefix.replace(/[^a-zA-Z0-9]/g, '')}`;
return virtualPath;
});
vi.spyOn(fs, 'rmSync').mockImplementation(() => {});
testDir = createTestDir();
vi.mocked(os.homedir).mockReturnValue(path.join(testDir.path, GEMINI_DIR));
vi.mocked(ExtensionStorage.getUserExtensionsDir).mockReturnValue(
path.join(testDir.path, GEMINI_DIR),
);
manager = new ExtensionEnablementManager();
});
afterEach(() => {
testDir.cleanup();
vi.restoreAllMocks();
// Reset the singleton instance for test isolation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(ExtensionEnablementManager as any).instance = undefined;
@@ -92,7 +144,7 @@ describe('ExtensionEnablementManager', () => {
);
});
it('should handle', () => {
it('should handle overlapping rules correctly', () => {
manager.enable('ext-test', true, '/home/user/projects');
manager.disable('ext-test', false, '/home/user/projects/my-app');
expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
@@ -104,6 +156,46 @@ describe('ExtensionEnablementManager', () => {
});
});
describe('remove', () => {
it('should remove an extension from the config', () => {
manager.enable('ext-test', true, '/path/to/dir');
const config = manager.readConfig();
expect(config['ext-test']).toBeDefined();
manager.remove('ext-test');
const newConfig = manager.readConfig();
expect(newConfig['ext-test']).toBeUndefined();
});
it('should not throw when removing a non-existent extension', () => {
const config = manager.readConfig();
expect(config['ext-test']).toBeUndefined();
expect(() => manager.remove('ext-test')).not.toThrow();
});
});
describe('readConfig', () => {
it('should return an empty object if the config file is corrupted', () => {
const configPath = path.join(
testDir.path,
GEMINI_DIR,
'extension-enablement.json',
);
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, 'not a json');
const config = manager.readConfig();
expect(config).toEqual({});
});
it('should return an empty object on generic read error', () => {
vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
throw new Error('Read error');
});
const config = manager.readConfig();
expect(config).toEqual({});
});
});
describe('includeSubdirs', () => {
it('should add a glob when enabling with includeSubdirs', () => {
manager.enable('ext-test', true, '/path/to/dir');
@@ -223,7 +315,7 @@ describe('ExtensionEnablementManager', () => {
});
});
it('should enable a path based on an enable override', () => {
it('should correctly prioritize more specific enable rules', () => {
manager.disable('ext-test', true, '/Users/chrstn');
manager.enable('ext-test', true, '/Users/chrstn/gemini-cli');
@@ -232,7 +324,7 @@ describe('ExtensionEnablementManager', () => {
);
});
it('should ignore subdirs', () => {
it('should not disable subdirectories if includeSubdirs is false', () => {
manager.disable('ext-test', false, '/Users/chrstn');
expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
true,
@@ -348,6 +440,13 @@ describe('Override', () => {
});
it('should create an override from a file rule', () => {
const override = Override.fromFileRule('/path/to/dir/');
expect(override.baseRule).toBe('/path/to/dir/');
expect(override.isDisable).toBe(false);
expect(override.includeSubdirs).toBe(false);
});
it('should create an override from a file rule without a trailing slash', () => {
const override = Override.fromFileRule('/path/to/dir');
expect(override.baseRule).toBe('/path/to/dir');
expect(override.isDisable).toBe(false);
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as path from 'node:path';
import * as os from 'node:os';
import {
getEnvContents,
maybePromptForSettings,
promptForSetting,
type ExtensionSetting,
@@ -188,6 +189,83 @@ describe('extensionSettings', () => {
expect(actualContent).toBe(expectedContent);
});
it('should clear settings if new config has no settings', async () => {
const previousConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{
name: 's2',
description: 'd2',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [],
};
const previousSettings = {
VAR1: 'previous-VAR1',
SENSITIVE_VAR: 'secret',
};
keychainData['SENSITIVE_VAR'] = 'secret';
const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1');
await maybePromptForSettings(
newConfig,
'12345',
mockRequestSetting,
previousConfig,
previousSettings,
);
expect(mockRequestSetting).not.toHaveBeenCalled();
const actualContent = await fsPromises.readFile(envPath, 'utf-8');
expect(actualContent).toBe('');
expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith(
'SENSITIVE_VAR',
);
});
it('should remove sensitive settings from keychain', async () => {
const previousConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{
name: 's1',
description: 'd1',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
const newConfig: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [],
};
const previousSettings = { SENSITIVE_VAR: 'secret' };
keychainData['SENSITIVE_VAR'] = 'secret';
await maybePromptForSettings(
newConfig,
'12345',
mockRequestSetting,
previousConfig,
previousSettings,
);
expect(mockKeychainStorage.deleteSecret).toHaveBeenCalledWith(
'SENSITIVE_VAR',
);
});
it('should remove settings that are no longer in the config', async () => {
const previousConfig: ExtensionConfig = {
name: 'test-ext',
@@ -343,5 +421,65 @@ describe('extensionSettings', () => {
});
expect(result).toBe(promptValue);
});
it('should return undefined if the user cancels the prompt', async () => {
vi.mocked(prompts).mockResolvedValue({ value: undefined });
const result = await promptForSetting({
name: 'Test',
description: 'Test desc',
envVar: 'TEST_VAR',
});
expect(result).toBeUndefined();
});
});
describe('getEnvContents', () => {
const config: ExtensionConfig = {
name: 'test-ext',
version: '1.0.0',
settings: [
{ name: 's1', description: 'd1', envVar: 'VAR1' },
{
name: 's2',
description: 'd2',
envVar: 'SENSITIVE_VAR',
sensitive: true,
},
],
};
it('should return combined contents from .env and keychain', async () => {
const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, 'VAR1=value1');
keychainData['SENSITIVE_VAR'] = 'secret';
const contents = await getEnvContents(config, '12345');
expect(contents).toEqual({
VAR1: 'value1',
SENSITIVE_VAR: 'secret',
});
});
it('should return an empty object if no settings are defined', async () => {
const contents = await getEnvContents(
{ name: 'test-ext', version: '1.0.0' },
'12345',
);
expect(contents).toEqual({});
});
it('should return only keychain contents if .env file does not exist', async () => {
keychainData['SENSITIVE_VAR'] = 'secret';
const contents = await getEnvContents(config, '12345');
expect(contents).toEqual({ SENSITIVE_VAR: 'secret' });
});
it('should return only .env contents if keychain is empty', async () => {
const envPath = path.join(extensionDir, '.env');
await fsPromises.writeFile(envPath, 'VAR1=value1');
const contents = await getEnvContents(config, '12345');
expect(contents).toEqual({ VAR1: 'value1' });
});
});
});
+294 -409
View File
@@ -4,484 +4,369 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockedFunction,
} from 'vitest';
import {
checkForExtensionUpdate,
cloneFromGit,
extractFile,
findReleaseAsset,
fetchReleaseFromGithub,
tryParseGithubUrl,
fetchReleaseFromGithub,
checkForExtensionUpdate,
downloadFromGitHubRelease,
findReleaseAsset,
downloadFile,
extractFile,
} from './github.js';
import { simpleGit, type SimpleGit } from 'simple-git';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import * as os from 'node:os';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as https from 'node:https';
import * as tar from 'tar';
import * as archiver from 'archiver';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import { ExtensionManager } from '../extension-manager.js';
import { loadSettings } from '../settings.js';
import type { ExtensionSetting } from './extensionSettings.js';
import * as extract from 'extract-zip';
import type { ExtensionManager } from '../extension-manager.js';
import { fetchJson } from './github_fetch.js';
import { EventEmitter } from 'node:events';
import type {
GeminiCLIExtension,
ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import type { ExtensionConfig } from '../extension.js';
const mockPlatform = vi.hoisted(() => vi.fn());
const mockArch = vi.hoisted(() => vi.fn());
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof os>();
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
platform: mockPlatform,
arch: mockArch,
Storage: {
getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/settings.json'),
getGlobalGeminiDir: vi.fn().mockReturnValue('/mock/.gemini'),
},
debugLogger: {
error: vi.fn(),
log: vi.fn(),
},
};
});
vi.mock('simple-git');
vi.mock('node:os');
vi.mock('node:fs');
vi.mock('node:https');
vi.mock('tar');
vi.mock('extract-zip');
vi.mock('./github_fetch.js');
vi.mock('../extension-manager.js');
// Mock settings.ts to avoid top-level side effects if possible, or just rely on Storage mock
vi.mock('../settings.js', () => ({
loadSettings: vi.fn(),
USER_SETTINGS_PATH: '/mock/settings.json',
}));
const fetchJsonMock = vi.hoisted(() => vi.fn());
vi.mock('./github_fetch.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./github_fetch.js')>();
return {
...actual,
fetchJson: fetchJsonMock,
};
});
describe('git extension helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
describe('github.ts', () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe('cloneFromGit', () => {
const mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
let mockGit: {
clone: ReturnType<typeof vi.fn>;
getRemotes: ReturnType<typeof vi.fn>;
fetch: ReturnType<typeof vi.fn>;
checkout: ReturnType<typeof vi.fn>;
listRemote: ReturnType<typeof vi.fn>;
revparse: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
};
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
});
it('should clone, fetch and checkout a repo', async () => {
const installMetadata = {
source: 'http://my-repo.com',
ref: 'my-ref',
type: 'git' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
await cloneFromGit(installMetadata, destination);
await cloneFromGit(
{
type: 'git',
source: 'https://github.com/owner/repo.git',
ref: 'v1.0.0',
},
'/dest',
);
expect(mockGit.clone).toHaveBeenCalledWith('http://my-repo.com', './', [
'--depth',
'1',
]);
expect(mockGit.getRemotes).toHaveBeenCalledWith(true);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'my-ref');
expect(mockGit.clone).toHaveBeenCalledWith(
'https://github.com/owner/repo.git',
'./',
['--depth', '1'],
);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'v1.0.0');
expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD');
});
it('should use HEAD if ref is not provided', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
await cloneFromGit(installMetadata, destination);
expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'HEAD');
});
it('should throw if no remotes are found', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
it('should throw if no remotes found', async () => {
mockGit.getRemotes.mockResolvedValue([]);
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
'Failed to clone Git repository from http://my-repo.com',
);
await expect(
cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
).rejects.toThrow('Unable to find any remotes');
});
it('should throw on clone error', async () => {
const installMetadata = {
source: 'http://my-repo.com',
type: 'git' as const,
};
const destination = '/dest';
mockGit.clone.mockRejectedValue(new Error('clone failed'));
mockGit.clone.mockRejectedValue(new Error('Clone failed'));
await expect(cloneFromGit(installMetadata, destination)).rejects.toThrow(
'Failed to clone Git repository from http://my-repo.com',
);
await expect(
cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
).rejects.toThrow('Failed to clone Git repository');
});
});
describe('checkForExtensionUpdate', () => {
const mockGit = {
getRemotes: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
};
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<
(consent: string) => Promise<boolean>
>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let tempHomeDir: string;
let tempWorkspaceDir: string;
beforeEach(() => {
tempHomeDir = fsSync.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fsSync.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
mockRequestConsent = vi.fn();
mockRequestConsent.mockResolvedValue(true);
mockPromptForSettings = vi.fn();
mockPromptForSettings.mockResolvedValue('');
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: loadSettings(tempWorkspaceDir).merged,
});
describe('tryParseGithubUrl', () => {
it.each([
['https://github.com/owner/repo', 'owner', 'repo'],
['https://github.com/owner/repo.git', 'owner', 'repo'],
['git@github.com:owner/repo.git', 'owner', 'repo'],
['owner/repo', 'owner', 'repo'],
])('should parse %s to %s/%s', (url, owner, repo) => {
expect(tryParseGithubUrl(url)).toEqual({ owner, repo });
});
it.each([
{
testName: 'should return NOT_UPDATABLE for non-git extensions',
extension: {
installMetadata: { type: 'link', source: '' },
},
mockSetup: () => {},
expected: ExtensionUpdateState.NOT_UPDATABLE,
},
{
testName: 'should return ERROR if no remotes found',
extension: {
installMetadata: { type: 'git', source: '' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([]);
},
expected: ExtensionUpdateState.ERROR,
},
{
testName:
'should return UPDATE_AVAILABLE when remote hash is different',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
},
expected: ExtensionUpdateState.UPDATE_AVAILABLE,
},
{
testName:
'should return UP_TO_DATE when remote and local hashes are the same',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://my-repo.com' } },
]);
mockGit.listRemote.mockResolvedValue('same-hash\tHEAD');
mockGit.revparse.mockResolvedValue('same-hash');
},
expected: ExtensionUpdateState.UP_TO_DATE,
},
{
testName: 'should return ERROR on git error',
extension: {
installMetadata: { type: 'git', source: 'my/ext' },
},
mockSetup: () => {
mockGit.getRemotes.mockRejectedValue(new Error('git error'));
},
expected: ExtensionUpdateState.ERROR,
},
])('$testName', async ({ extension, mockSetup, expected }) => {
const fullExtension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
contextFiles: [],
...extension,
} as unknown as GeminiCLIExtension;
mockSetup();
const result = await checkForExtensionUpdate(
fullExtension,
extensionManager,
'https://gitlab.com/owner/repo',
'https://my-git-host.com/owner/group/repo',
'git@gitlab.com:some-group/some-project/some-repo.git',
])('should return null for non-GitHub URLs', (url) => {
expect(tryParseGithubUrl(url)).toBeNull();
});
it('should throw for invalid formats', () => {
expect(() => tryParseGithubUrl('invalid')).toThrow(
'Invalid GitHub repository source',
);
expect(result).toBe(expected);
});
});
describe('fetchReleaseFromGithub', () => {
it.each([
{
ref: undefined,
allowPreRelease: true,
mockedResponse: [{ tag_name: 'v1.0.0-alpha' }, { tag_name: 'v0.9.0' }],
expectedUrl:
'https://api.github.com/repos/owner/repo/releases?per_page=1',
expectedResult: { tag_name: 'v1.0.0-alpha' },
},
{
ref: undefined,
allowPreRelease: false,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl: 'https://api.github.com/repos/owner/repo/releases/latest',
expectedResult: { tag_name: 'v0.9.0' },
},
{
ref: 'v0.9.0',
allowPreRelease: undefined,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl:
'https://api.github.com/repos/owner/repo/releases/tags/v0.9.0',
expectedResult: { tag_name: 'v0.9.0' },
},
{
ref: undefined,
allowPreRelease: undefined,
mockedResponse: { tag_name: 'v0.9.0' },
expectedUrl: 'https://api.github.com/repos/owner/repo/releases/latest',
expectedResult: { tag_name: 'v0.9.0' },
},
])(
'should fetch release with ref=$ref and allowPreRelease=$allowPreRelease',
async ({
ref,
allowPreRelease,
mockedResponse,
expectedUrl,
expectedResult,
}) => {
fetchJsonMock.mockResolvedValueOnce(mockedResponse);
it('should fetch latest release if no ref provided', async () => {
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
const result = await fetchReleaseFromGithub(
'owner',
'repo',
ref,
allowPreRelease,
);
await fetchReleaseFromGithub('owner', 'repo');
expect(fetchJsonMock).toHaveBeenCalledWith(expectedUrl);
expect(result).toEqual(expectedResult);
},
);
expect(fetchJson).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/latest',
);
});
it('should fetch specific ref if provided', async () => {
vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
await fetchReleaseFromGithub('owner', 'repo', 'v1.0.0');
expect(fetchJson).toHaveBeenCalledWith(
'https://api.github.com/repos/owner/repo/releases/tags/v1.0.0',
);
});
it('should handle pre-releases if allowed', async () => {
vi.mocked(fetchJson).mockResolvedValueOnce([{ tag_name: 'v1.0.0-beta' }]);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
undefined,
true,
);
expect(result).toEqual({ tag_name: 'v1.0.0-beta' });
});
it('should return null if no releases found', async () => {
vi.mocked(fetchJson).mockResolvedValueOnce([]);
const result = await fetchReleaseFromGithub(
'owner',
'repo',
undefined,
true,
);
expect(result).toBeNull();
});
});
describe('checkForExtensionUpdate', () => {
let mockExtensionManager: ExtensionManager;
let mockGit: {
getRemotes: ReturnType<typeof vi.fn>;
listRemote: ReturnType<typeof vi.fn>;
revparse: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockExtensionManager = {
loadExtensionConfig: vi.fn(),
} as unknown as ExtensionManager;
mockGit = {
getRemotes: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
};
vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
});
it('should return NOT_UPDATABLE for non-git/non-release extensions', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
version: '1.0.0',
} as unknown as ExtensionConfig);
const linkExt = {
installMetadata: { type: 'link' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(linkExt, mockExtensionManager)).toBe(
ExtensionUpdateState.NOT_UPDATABLE,
);
});
it('should return UPDATE_AVAILABLE if git remote hash differs', async () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'url' } },
]);
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.revparse.mockResolvedValue('local-hash');
const ext = {
path: '/path',
installMetadata: { type: 'git', source: 'url' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
});
it('should return UP_TO_DATE if git remote hash matches', async () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'url' } },
]);
mockGit.listRemote.mockResolvedValue('hash\tHEAD');
mockGit.revparse.mockResolvedValue('hash');
const ext = {
path: '/path',
installMetadata: { type: 'git', source: 'url' },
} as unknown as GeminiCLIExtension;
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
ExtensionUpdateState.UP_TO_DATE,
);
});
});
describe('downloadFromGitHubRelease', () => {
it('should fail if no release data found', async () => {
// Mock fetchJson to throw for latest release check
vi.mocked(fetchJson).mockRejectedValue(new Error('Not found'));
const result = await downloadFromGitHubRelease(
{
type: 'github-release',
source: 'owner/repo',
ref: 'v1',
} as unknown as ExtensionInstallMetadata,
'/dest',
{ owner: 'owner', repo: 'repo' },
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.failureReason).toBe('failed to fetch release data');
}
});
});
describe('findReleaseAsset', () => {
const assets = [
{ name: 'darwin.arm64.extension.tar.gz', url: 'url1' },
{ name: 'darwin.x64.extension.tar.gz', url: 'url2' },
{ name: 'linux.x64.extension.tar.gz', url: 'url3' },
{ name: 'win32.x64.extension.tar.gz', url: 'url4' },
{ name: 'extension-generic.tar.gz', url: 'url5' },
];
it.each([
{ platform: 'darwin', arch: 'arm64', expected: assets[0] },
{ platform: 'linux', arch: 'arm64', expected: assets[2] },
{ platform: 'sunos', arch: 'x64', expected: undefined },
])(
'should find asset matching platform and architecture',
({ platform, arch, expected }) => {
mockPlatform.mockReturnValue(platform);
mockArch.mockReturnValue(arch);
const result = findReleaseAsset(assets);
expect(result).toEqual(expected);
},
);
it('should find generic asset if it is the only one', () => {
const singleAsset = [{ name: 'extension.tar.gz', url: 'aurl5' }];
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(singleAsset);
expect(result).toEqual(singleAsset[0]);
it('should find platform/arch specific asset', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
vi.mocked(os.arch).mockReturnValue('arm64');
const assets = [
{ name: 'darwin.arm64.tar.gz', url: 'url1' },
{ name: 'linux.x64.tar.gz', url: 'url2' },
];
expect(findReleaseAsset(assets)).toEqual(assets[0]);
});
it('should return undefined if multiple generic assets exist', () => {
const multipleGenericAssets = [
{ name: 'extension-1.tar.gz', url: 'aurl1' },
{ name: 'extension-2.tar.gz', url: 'aurl2' },
];
mockPlatform.mockReturnValue('darwin');
mockArch.mockReturnValue('arm64');
const result = findReleaseAsset(multipleGenericAssets);
expect(result).toBeUndefined();
it('should find generic asset', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
const assets = [{ name: 'generic.tar.gz', url: 'url' }];
expect(findReleaseAsset(assets)).toEqual(assets[0]);
});
});
describe('parseGitHubRepoForReleases', () => {
it.each([
{
source: 'https://github.com/owner/repo.git',
owner: 'owner',
repo: 'repo',
},
{
source: 'https://github.com/owner/repo',
owner: 'owner',
repo: 'repo',
},
{
source: 'https://github.com/owner/repo/',
owner: 'owner',
repo: 'repo',
},
{
source: 'git@github.com:owner/repo.git',
owner: 'owner',
repo: 'repo',
},
{ source: 'owner/repo', owner: 'owner', repo: 'repo' },
{ source: 'owner/repo.git', owner: 'owner', repo: 'repo' },
])(
'should parse owner and repo from $source',
({ source, owner, repo }) => {
const result = tryParseGithubUrl(source)!;
expect(result.owner).toBe(owner);
expect(result.repo).toBe(repo);
},
);
describe('downloadFile', () => {
it('should download file successfully', async () => {
const mockReq = new EventEmitter();
const mockRes =
new EventEmitter() as unknown as import('node:http').IncomingMessage;
Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
it('should return null on a non-GitHub URL', () => {
const source = 'https://example.com/owner/repo.git';
expect(tryParseGithubUrl(source)).toBe(null);
vi.mocked(https.get).mockImplementation((url, options, cb) => {
if (typeof options === 'function') {
cb = options;
}
if (cb) cb(mockRes);
return mockReq as unknown as import('node:http').ClientRequest;
});
const mockStream = new EventEmitter() as unknown as fs.WriteStream;
Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
const promise = downloadFile('url', '/dest');
mockRes.emit('end');
mockStream.emit('finish');
await expect(promise).resolves.toBeUndefined();
});
it.each([
{ source: 'invalid-format' },
{ source: 'https://github.com/owner/repo/extra' },
])(
'should throw error for invalid source format: $source',
({ source }) => {
expect(() => tryParseGithubUrl(source)).toThrow(
`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
);
},
);
it('should fail on non-200 status', async () => {
const mockReq = new EventEmitter();
const mockRes =
new EventEmitter() as unknown as import('node:http').IncomingMessage;
Object.assign(mockRes, { statusCode: 404 });
vi.mocked(https.get).mockImplementation((url, options, cb) => {
if (typeof options === 'function') {
cb = options;
}
if (cb) cb(mockRes);
return mockReq as unknown as import('node:http').ClientRequest;
});
await expect(downloadFile('url', '/dest')).rejects.toThrow(
'Request failed with status code 404',
);
});
});
describe('extractFile', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
it('should extract tar.gz using tar', async () => {
await extractFile('file.tar.gz', '/dest');
expect(tar.x).toHaveBeenCalled();
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
it('should extract zip using extract-zip', async () => {
vi.mocked(extract.default || extract).mockResolvedValue(undefined);
await extractFile('file.zip', '/dest');
// Check if extract was called. Note: extract-zip export might be default or named depending on mock
});
it('should extract a .tar.gz file', async () => {
const archivePath = path.join(tempDir, 'test.tar.gz');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
// Create a dummy file to be archived
const dummyFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(dummyFilePath, 'hello tar');
// Create the tar.gz file
await tar.c(
{
gzip: true,
file: archivePath,
cwd: tempDir,
},
['test.txt'],
it('should throw for unsupported extensions', async () => {
await expect(extractFile('file.txt', '/dest')).rejects.toThrow(
'Unsupported file extension',
);
await extractFile(archivePath, extractionDest);
const extractedFilePath = path.join(extractionDest, 'test.txt');
const content = await fs.readFile(extractedFilePath, 'utf-8');
expect(content).toBe('hello tar');
});
it('should extract a .zip file', async () => {
const archivePath = path.join(tempDir, 'test.zip');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
// Create a dummy file to be archived
const dummyFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(dummyFilePath, 'hello zip');
// Create the zip file
const output = fsSync.createWriteStream(archivePath);
const archive = archiver.create('zip');
const streamFinished = new Promise((resolve, reject) => {
output.on('close', () => resolve(null));
archive.on('error', reject);
});
archive.pipe(output);
archive.file(dummyFilePath, { name: 'test.txt' });
await archive.finalize();
await streamFinished;
await extractFile(archivePath, extractionDest);
const extractedFilePath = path.join(extractionDest, 'test.txt');
const content = await fs.readFile(extractedFilePath, 'utf-8');
expect(content).toBe('hello zip');
});
it('should throw an error for unsupported file types', async () => {
const unsupportedFilePath = path.join(tempDir, 'test.txt');
await fs.writeFile(unsupportedFilePath, 'some content');
const extractionDest = path.join(tempDir, 'extracted');
await fs.mkdir(extractionDest);
await expect(
extractFile(unsupportedFilePath, extractionDest),
).rejects.toThrow('Unsupported file extension for extraction:');
});
});
});
+20 -5
View File
@@ -84,12 +84,27 @@ export interface GithubRepoInfo {
}
export function tryParseGithubUrl(source: string): GithubRepoInfo | null {
// First step in normalizing a github ssh URI to the https form.
if (source.startsWith('git@github.com:')) {
source = source.replace('git@github.com:', '');
// Handle SCP-style SSH URLs.
if (source.startsWith('git@')) {
if (source.startsWith('git@github.com:')) {
// It's a GitHub SSH URL, so normalize it for the URL parser.
source = source.replace('git@github.com:', '');
} else {
// It's another provider's SSH URL (e.g., gitlab), so not a GitHub repo.
return null;
}
}
// Default to a github repo path, so `source` can be just an org/repo
const parsedUrl = URL.parse(source, 'https://github.com');
let parsedUrl: URL;
try {
// Use the standard URL constructor for backward compatibility.
parsedUrl = new URL(source, 'https://github.com');
} catch (e) {
// Throw a TypeError to maintain a consistent error contract for invalid URLs.
// This avoids a breaking change for consumers who might expect a TypeError.
throw new TypeError(`Invalid repo URL: ${source}`, { cause: e });
}
if (!parsedUrl) {
throw new Error(`Invalid repo URL: ${source}`);
}
@@ -457,7 +472,7 @@ export function findReleaseAsset(assets: Asset[]): Asset | undefined {
return undefined;
}
async function downloadFile(url: string, dest: string): Promise<void> {
export async function downloadFile(url: string, dest: string): Promise<void> {
const headers: {
'User-agent': string;
Accept: string;
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExtensionStorage } from './storage.js';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs';
import {
EXTENSION_SETTINGS_FILENAME,
EXTENSIONS_CONFIG_FILENAME,
} from './variables.js';
import { Storage } from '@google/gemini-cli-core';
vi.mock('node:os');
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof fs>();
return {
...actual,
promises: {
...actual.promises,
mkdtemp: vi.fn(),
},
};
});
vi.mock('@google/gemini-cli-core');
describe('ExtensionStorage', () => {
const mockHomeDir = '/mock/home';
const extensionName = 'test-extension';
let storage: ExtensionStorage;
beforeEach(() => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(Storage).mockImplementation(
() =>
({
getExtensionsDir: () =>
path.join(mockHomeDir, '.gemini', 'extensions'),
}) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
);
storage = new ExtensionStorage(extensionName);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return the correct extension directory', () => {
const expectedDir = path.join(
mockHomeDir,
'.gemini',
'extensions',
extensionName,
);
expect(storage.getExtensionDir()).toBe(expectedDir);
});
it('should return the correct config path', () => {
const expectedPath = path.join(
mockHomeDir,
'.gemini',
'extensions',
extensionName,
EXTENSIONS_CONFIG_FILENAME, // EXTENSIONS_CONFIG_FILENAME
);
expect(storage.getConfigPath()).toBe(expectedPath);
});
it('should return the correct env file path', () => {
const expectedPath = path.join(
mockHomeDir,
'.gemini',
'extensions',
extensionName,
EXTENSION_SETTINGS_FILENAME, // EXTENSION_SETTINGS_FILENAME
);
expect(storage.getEnvFilePath()).toBe(expectedPath);
});
it('should return the correct user extensions directory', () => {
const expectedDir = path.join(mockHomeDir, '.gemini', 'extensions');
expect(ExtensionStorage.getUserExtensionsDir()).toBe(expectedDir);
});
it('should create a temporary directory', async () => {
const mockTmpDir = '/tmp/gemini-extension-123';
vi.mocked(fs.promises.mkdtemp).mockResolvedValue(mockTmpDir);
vi.mocked(os.tmpdir).mockReturnValue('/tmp');
const result = await ExtensionStorage.createTmpDir();
expect(fs.promises.mkdtemp).toHaveBeenCalledWith(
path.join('/tmp', 'gemini-extension'),
);
expect(result).toBe(mockTmpDir);
});
});
+281 -391
View File
@@ -4,448 +4,338 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, type MockedFunction } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { checkForAllExtensionUpdates, updateExtension } from './update.js';
import { GEMINI_DIR, KeychainTokenStorage } from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from '../trustedFolders.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { createExtension } from '../../test-utils/createExtension.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
} from './variables.js';
import { ExtensionManager } from '../extension-manager.js';
import { loadSettings } from '../settings.js';
import type { ExtensionSetting } from './extensionSettings.js';
updateExtension,
updateAllUpdatableExtensions,
checkForAllExtensionUpdates,
} from './update.js';
import {
ExtensionUpdateState,
type ExtensionUpdateStatus,
} from '../../ui/state/extensions.js';
import { ExtensionStorage } from './storage.js';
import { copyExtension } from '../extension-manager.js';
import { checkForExtensionUpdate } from './github.js';
import { loadInstallMetadata } from '../extension.js';
import * as fs from 'node:fs';
import type { ExtensionManager } from '../extension-manager.js';
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
const mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
// Not a part of the actual API, but we need to use this to do the correct
// file system interactions.
path: vi.fn(),
};
vi.mock('simple-git', () => ({
simpleGit: vi.fn((path: string) => {
mockGit.path.mockReturnValue(path);
return mockGit;
}),
// Mock dependencies
vi.mock('./storage.js', () => ({
ExtensionStorage: {
createTmpDir: vi.fn(),
},
}));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
vi.mock('../trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
vi.mock('../extension-manager.js', () => ({
copyExtension: vi.fn(),
// We don't need to mock the class implementation if we pass a mock instance
}));
const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn());
const mockLogExtensionUninstall = vi.hoisted(() => vi.fn());
vi.mock('./github.js', () => ({
checkForExtensionUpdate: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
vi.mock('../extension.js', () => ({
loadInstallMetadata: vi.fn(),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
logExtensionInstallEvent: mockLogExtensionInstallEvent,
logExtensionUninstall: mockLogExtensionUninstall,
ExtensionInstallEvent: vi.fn(),
ExtensionUninstallEvent: vi.fn(),
KeychainTokenStorage: vi.fn().mockImplementation(() => ({
getSecret: vi.fn(),
setSecret: vi.fn(),
deleteSecret: vi.fn(),
listSecrets: vi.fn(),
isAvailable: vi.fn().mockResolvedValue(true),
})),
promises: {
...actual.promises,
rm: vi.fn(),
},
};
});
interface MockKeychainStorage {
getSecret: ReturnType<typeof vi.fn>;
setSecret: ReturnType<typeof vi.fn>;
deleteSecret: ReturnType<typeof vi.fn>;
listSecrets: ReturnType<typeof vi.fn>;
isAvailable: ReturnType<typeof vi.fn>;
}
describe('update tests', () => {
let tempHomeDir: string;
let tempWorkspaceDir: string;
let userExtensionsDir: string;
let extensionManager: ExtensionManager;
let mockRequestConsent: MockedFunction<(consent: string) => Promise<boolean>>;
let mockPromptForSettings: MockedFunction<
(setting: ExtensionSetting) => Promise<string>
>;
let mockKeychainStorage: MockKeychainStorage;
let keychainData: Record<string, string>;
describe('Extension Update Logic', () => {
let mockExtensionManager: ExtensionManager;
let mockDispatch: ReturnType<typeof vi.fn>;
const mockExtension: GeminiCLIExtension = {
name: 'test-extension',
version: '1.0.0',
path: '/path/to/extension',
} as GeminiCLIExtension;
beforeEach(() => {
vi.clearAllMocks();
keychainData = {};
mockKeychainStorage = {
getSecret: vi
.fn()
.mockImplementation(async (key: string) => keychainData[key] || null),
setSecret: vi
.fn()
.mockImplementation(async (key: string, value: string) => {
keychainData[key] = value;
}),
deleteSecret: vi.fn().mockImplementation(async (key: string) => {
delete keychainData[key];
}),
listSecrets: vi
.fn()
.mockImplementation(async () => Object.keys(keychainData)),
isAvailable: vi.fn().mockResolvedValue(true),
};
(
KeychainTokenStorage as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockKeychainStorage);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
// Clean up before each test
fs.rmSync(userExtensionsDir, { recursive: true, force: true });
fs.mkdirSync(userExtensionsDir, { recursive: true });
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});
vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
Object.values(mockGit).forEach((fn) => fn.mockReset());
mockRequestConsent = vi.fn();
mockRequestConsent.mockResolvedValue(true);
mockPromptForSettings = vi.fn();
mockPromptForSettings.mockResolvedValue('');
extensionManager = new ExtensionManager({
workspaceDir: tempWorkspaceDir,
requestConsent: mockRequestConsent,
requestSetting: mockPromptForSettings,
settings: loadSettings(tempWorkspaceDir).merged,
});
});
mockExtensionManager = {
loadExtensionConfig: vi.fn(),
installOrUpdateExtension: vi.fn(),
} as unknown as ExtensionManager;
mockDispatch = vi.fn();
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
vi.restoreAllMocks();
// Default mock behaviors
vi.mocked(ExtensionStorage.createTmpDir).mockResolvedValue('/tmp/mock-dir');
vi.mocked(loadInstallMetadata).mockReturnValue({
source: 'https://example.com/repo.git',
type: 'git',
});
});
describe('updateExtension', () => {
it('should update a git-installed extension', async () => {
const gitUrl = 'https://github.com/google/gemini-extensions.git';
const extensionName = 'gemini-extensions';
const targetExtDir = path.join(userExtensionsDir, extensionName);
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
fs.mkdirSync(targetExtDir, { recursive: true });
fs.writeFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.0.0' }),
it('should return undefined if state is already UPDATING', async () => {
const result = await updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATING,
mockDispatch,
);
fs.writeFileSync(
metadataPath,
JSON.stringify({ source: gitUrl, type: 'git' }),
);
mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.1.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName)!;
const updateInfo = await updateExtension(
extension!,
extensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
() => {},
);
expect(updateInfo).toEqual({
name: 'gemini-extensions',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
const updatedConfig = JSON.parse(
fs.readFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
'utf-8',
),
);
expect(updatedConfig.version).toBe('1.1.0');
expect(result).toBeUndefined();
expect(mockDispatch).not.toHaveBeenCalled();
});
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
const extensionName = 'test-extension';
createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
it('should throw error and set state to ERROR if install metadata type is unknown', async () => {
vi.mocked(loadInstallMetadata).mockReturnValue({
type: undefined,
} as unknown as import('@google/gemini-cli-core').ExtensionInstallMetadata);
mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.1.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName)!;
await updateExtension(
extension!,
extensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
state: ExtensionUpdateState.UPDATING,
},
});
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
},
});
});
it('should call setExtensionUpdateState with ERROR on failure', async () => {
const extensionName = 'test-extension';
createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const dispatch = vi.fn();
const extensions = await extensionManager.loadExtensions();
const extension = extensions.find((e) => e.name === extensionName)!;
await expect(
updateExtension(
extension!,
extensionManager,
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
dispatch,
mockDispatch,
),
).rejects.toThrow();
).rejects.toThrow('type is unknown');
expect(dispatch).toHaveBeenCalledWith({
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
name: mockExtension.name,
state: ExtensionUpdateState.UPDATING,
},
});
expect(dispatch).toHaveBeenCalledWith({
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: extensionName,
name: mockExtension.name,
state: ExtensionUpdateState.ERROR,
},
});
});
it('should throw error and set state to UP_TO_DATE if extension is linked', async () => {
vi.mocked(loadInstallMetadata).mockReturnValue({
type: 'link',
source: '',
});
await expect(
updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
),
).rejects.toThrow('Extension is linked');
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should successfully update extension and set state to UPDATED_NEEDS_RESTART by default', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'test-extension',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({
...mockExtension,
version: '1.1.0',
});
const result = await updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
);
expect(mockExtensionManager.installOrUpdateExtension).toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
},
});
expect(result).toEqual({
name: 'test-extension',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/mock-dir', {
recursive: true,
force: true,
});
});
it('should set state to UPDATED if enableExtensionReloading is true', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'test-extension',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({
...mockExtension,
version: '1.1.0',
});
await updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
true, // enableExtensionReloading
);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UPDATED,
},
});
});
it('should rollback and set state to ERROR if installation fails', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'test-extension',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockRejectedValue(new Error('Install failed'));
await expect(
updateExtension(
mockExtension,
mockExtensionManager,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockDispatch,
),
).rejects.toThrow('Updated extension not found after installation');
expect(copyExtension).toHaveBeenCalledWith(
'/tmp/mock-dir',
mockExtension.path,
);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.ERROR,
},
});
expect(fs.promises.rm).toHaveBeenCalled();
});
});
describe('updateAllUpdatableExtensions', () => {
it('should update all extensions with UPDATE_AVAILABLE status', async () => {
const extensions: GeminiCLIExtension[] = [
{ ...mockExtension, name: 'ext1' },
{ ...mockExtension, name: 'ext2' },
{ ...mockExtension, name: 'ext3' },
];
const extensionsState = new Map([
['ext1', { status: ExtensionUpdateState.UPDATE_AVAILABLE }],
['ext2', { status: ExtensionUpdateState.UP_TO_DATE }],
['ext3', { status: ExtensionUpdateState.UPDATE_AVAILABLE }],
]);
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue({
name: 'ext',
version: '1.0.0',
});
vi.mocked(
mockExtensionManager.installOrUpdateExtension,
).mockResolvedValue({ ...mockExtension, version: '1.1.0' });
const results = await updateAllUpdatableExtensions(
extensions,
extensionsState as Map<string, ExtensionUpdateStatus>,
mockExtensionManager,
mockDispatch,
);
expect(results).toHaveLength(2);
expect(results.map((r) => r.name)).toEqual(['ext1', 'ext3']);
expect(
mockExtensionManager.installOrUpdateExtension,
).toHaveBeenCalledTimes(2);
});
});
describe('checkForAllExtensionUpdates', () => {
it('should return UpdateAvailable for a git extension with updates', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
it('should dispatch BATCH_CHECK_START and BATCH_CHECK_END', async () => {
await checkForAllExtensionUpdates([], mockExtensionManager, mockDispatch);
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
]);
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
mockGit.revparse.mockResolvedValue('localHash');
expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_START' });
expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_END' });
});
it('should set state to NOT_UPDATABLE if no install metadata', async () => {
const extensions: GeminiCLIExtension[] = [
{ ...mockExtension, installMetadata: undefined },
];
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
extensions,
mockExtensionManager,
mockDispatch,
);
expect(dispatch).toHaveBeenCalledWith({
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'test-extension',
name: mockExtension.name,
state: ExtensionUpdateState.NOT_UPDATABLE,
},
});
});
it('should check for updates and update state', async () => {
const extensions: GeminiCLIExtension[] = [
{ ...mockExtension, installMetadata: { type: 'git', source: '...' } },
];
vi.mocked(checkForExtensionUpdate).mockResolvedValue(
ExtensionUpdateState.UPDATE_AVAILABLE,
);
await checkForAllExtensionUpdates(
extensions,
mockExtensionManager,
mockDispatch,
);
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.CHECKING_FOR_UPDATES,
},
});
expect(mockDispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: mockExtension.name,
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
});
it('should return UpToDate for a git extension with no updates', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
]);
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
mockGit.revparse.mockResolvedValue('sameHash');
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'test-extension',
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should return UpToDate for a local extension with no updates', async () => {
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
const sourceExtensionDir = createExtension({
extensionsDir: localExtensionSourcePath,
name: 'my-local-ext',
version: '1.0.0',
});
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'local-extension',
state: ExtensionUpdateState.UP_TO_DATE,
},
});
});
it('should return UpdateAvailable for a local extension with updates', async () => {
const localExtensionSourcePath = path.join(tempHomeDir, 'local-source');
const sourceExtensionDir = createExtension({
extensionsDir: localExtensionSourcePath,
name: 'local-extension',
version: '1.1.0',
});
createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: sourceExtensionDir, type: 'local' },
});
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'local-extension',
state: ExtensionUpdateState.UPDATE_AVAILABLE,
},
});
});
it('should return Error when git check fails', async () => {
createExtension({
extensionsDir: userExtensionsDir,
name: 'error-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
const dispatch = vi.fn();
await checkForAllExtensionUpdates(
await extensionManager.loadExtensions(),
extensionManager,
dispatch,
);
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_STATE',
payload: {
name: 'error-extension',
state: ExtensionUpdateState.ERROR,
},
});
});
});
});
@@ -5,7 +5,32 @@
*/
import { expect, describe, it } from 'vitest';
import { hydrateString } from './variables.js';
import {
hydrateString,
recursivelyHydrateStrings,
validateVariables,
type VariableContext,
} from './variables.js';
describe('validateVariables', () => {
it('should not throw if all required variables are present', () => {
const schema = {
extensionPath: { type: 'string', description: 'test', required: true },
} as const;
const context = { extensionPath: 'value' };
expect(() => validateVariables(context, schema)).not.toThrow();
});
it('should throw if a required variable is missing', () => {
const schema = {
extensionPath: { type: 'string', description: 'test', required: true },
} as const;
const context = {};
expect(() => validateVariables(context, schema)).toThrow(
'Missing required variable: extensionPath',
);
});
});
describe('hydrateString', () => {
it('should replace a single variable', () => {
@@ -15,4 +40,88 @@ describe('hydrateString', () => {
const result = hydrateString('Hello, ${extensionPath}!', context);
expect(result).toBe('Hello, path/my-extension!');
});
it('should replace multiple variables', () => {
const context = {
extensionPath: 'path/my-extension',
workspacePath: '/ws',
};
const result = hydrateString(
'Ext: ${extensionPath}, WS: ${workspacePath}',
context,
);
expect(result).toBe('Ext: path/my-extension, WS: /ws');
});
it('should ignore unknown variables', () => {
const context = {
extensionPath: 'path/my-extension',
};
const result = hydrateString('Hello, ${unknown}!', context);
expect(result).toBe('Hello, ${unknown}!');
});
it('should handle null and undefined context values', () => {
const context: VariableContext = {
extensionPath: undefined,
};
const result = hydrateString(
'Ext: ${extensionPath}, WS: ${workspacePath}',
context,
);
expect(result).toBe('Ext: ${extensionPath}, WS: ${workspacePath}');
});
});
describe('recursivelyHydrateStrings', () => {
const context = {
extensionPath: 'path/my-extension',
workspacePath: '/ws',
};
it('should hydrate strings in a flat object', () => {
const obj = {
a: 'Hello, ${workspacePath}',
b: 'Hi, ${extensionPath}',
};
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual({
a: 'Hello, /ws',
b: 'Hi, path/my-extension',
});
});
it('should hydrate strings in an array', () => {
const arr = ['${workspacePath}', '${extensionPath}'];
const result = recursivelyHydrateStrings(arr, context);
expect(result).toEqual(['/ws', 'path/my-extension']);
});
it('should hydrate strings in a nested object', () => {
const obj = {
a: 'Hello, ${workspacePath}',
b: {
c: 'Hi, ${extensionPath}',
d: ['${workspacePath}/foo'],
},
};
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual({
a: 'Hello, /ws',
b: {
c: 'Hi, path/my-extension',
d: ['/ws/foo'],
},
});
});
it('should not modify non-string values', () => {
const obj = {
a: 123,
b: true,
c: null,
};
const result = recursivelyHydrateStrings(obj, context);
expect(result).toEqual(obj);
});
});
@@ -0,0 +1,228 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { getPackageJson } from '@google/gemini-cli-core';
import commandExists from 'command-exists';
import * as os from 'node:os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadSandboxConfig } from './sandboxConfig.js';
// Mock dependencies
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as object),
getPackageJson: vi.fn(),
FatalSandboxError: class extends Error {
constructor(message: string) {
super(message);
this.name = 'FatalSandboxError';
}
},
};
});
vi.mock('command-exists', () => ({
default: {
sync: vi.fn(),
},
}));
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal();
return {
...(actual as object),
platform: vi.fn(),
};
});
const mockedGetPackageJson = vi.mocked(getPackageJson);
const mockedCommandExistsSync = vi.mocked(commandExists.sync);
const mockedOsPlatform = vi.mocked(os.platform);
describe('loadSandboxConfig', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
process.env = { ...originalEnv };
mockedGetPackageJson.mockResolvedValue({
config: { sandboxImageUri: 'default/image' },
});
});
afterEach(() => {
process.env = originalEnv;
});
it('should return undefined if sandbox is explicitly disabled via argv', async () => {
const config = await loadSandboxConfig({}, { sandbox: false });
expect(config).toBeUndefined();
});
it('should return undefined if sandbox is explicitly disabled via settings', async () => {
const config = await loadSandboxConfig({ tools: { sandbox: false } }, {});
expect(config).toBeUndefined();
});
it('should return undefined if sandbox is not configured', async () => {
const config = await loadSandboxConfig({}, {});
expect(config).toBeUndefined();
});
it('should return undefined if already inside a sandbox (SANDBOX env var is set)', async () => {
process.env['SANDBOX'] = '1';
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toBeUndefined();
});
describe('with GEMINI_SANDBOX environment variable', () => {
it('should use docker if GEMINI_SANDBOX=docker and it exists', async () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
expect(mockedCommandExistsSync).toHaveBeenCalledWith('docker');
});
it('should throw if GEMINI_SANDBOX is an invalid command', async () => {
process.env['GEMINI_SANDBOX'] = 'invalid-command';
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec",
);
});
it('should throw if GEMINI_SANDBOX command does not exist', async () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(false);
await expect(loadSandboxConfig({}, {})).rejects.toThrow(
"Missing sandbox command 'docker' (from GEMINI_SANDBOX)",
);
});
});
describe('with sandbox: true', () => {
it('should use sandbox-exec on darwin if available', async () => {
mockedOsPlatform.mockReturnValue('darwin');
mockedCommandExistsSync.mockImplementation(
(cmd) => cmd === 'sandbox-exec',
);
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({
command: 'sandbox-exec',
image: 'default/image',
});
});
it('should prefer sandbox-exec over docker on darwin', async () => {
mockedOsPlatform.mockReturnValue('darwin');
mockedCommandExistsSync.mockReturnValue(true); // all commands exist
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({
command: 'sandbox-exec',
image: 'default/image',
});
});
it('should use docker if available and sandbox is true', async () => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker');
const config = await loadSandboxConfig({ tools: { sandbox: true } }, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
});
it('should use podman if available and docker is not', async () => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'podman');
const config = await loadSandboxConfig({}, { sandbox: true });
expect(config).toEqual({ command: 'podman', image: 'default/image' });
});
it('should throw if sandbox: true but no command is found', async () => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockReturnValue(false);
await expect(loadSandboxConfig({}, { sandbox: true })).rejects.toThrow(
'GEMINI_SANDBOX is true but failed to determine command for sandbox; ' +
'install docker or podman or specify command in GEMINI_SANDBOX',
);
});
});
describe("with sandbox: 'command'", () => {
it('should use the specified command if it exists', async () => {
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, { sandbox: 'podman' });
expect(config).toEqual({ command: 'podman', image: 'default/image' });
expect(mockedCommandExistsSync).toHaveBeenCalledWith('podman');
});
it('should throw if the specified command does not exist', async () => {
mockedCommandExistsSync.mockReturnValue(false);
await expect(
loadSandboxConfig({}, { sandbox: 'podman' }),
).rejects.toThrow(
"Missing sandbox command 'podman' (from GEMINI_SANDBOX)",
);
});
it('should throw if the specified command is invalid', async () => {
await expect(
loadSandboxConfig({}, { sandbox: 'invalid-command' }),
).rejects.toThrow(
"Invalid sandbox command 'invalid-command'. Must be one of docker, podman, sandbox-exec",
);
});
});
describe('image configuration', () => {
it('should use image from GEMINI_SANDBOX_IMAGE env var if set', async () => {
process.env['GEMINI_SANDBOX_IMAGE'] = 'env/image';
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'env/image' });
});
it('should use image from package.json if env var is not set', async () => {
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toEqual({ command: 'docker', image: 'default/image' });
});
it('should return undefined if command is found but no image is configured', async () => {
mockedGetPackageJson.mockResolvedValue({}); // no sandboxImageUri
process.env['GEMINI_SANDBOX'] = 'docker';
mockedCommandExistsSync.mockReturnValue(true);
const config = await loadSandboxConfig({}, {});
expect(config).toBeUndefined();
});
});
describe('truthy/falsy sandbox values', () => {
beforeEach(() => {
mockedOsPlatform.mockReturnValue('linux');
mockedCommandExistsSync.mockImplementation((cmd) => cmd === 'docker');
});
it.each([true, 'true', '1'])(
'should enable sandbox for value: %s',
async (value) => {
const config = await loadSandboxConfig({}, { sandbox: value });
expect(config).toEqual({ command: 'docker', image: 'default/image' });
},
);
it.each([false, 'false', '0', undefined, null, ''])(
'should disable sandbox for value: %s',
async (value) => {
// \`null\` is not a valid type for the arg, but good to test falsiness
const config = await loadSandboxConfig({}, { sandbox: value });
expect(config).toBeUndefined();
},
);
});
});
+2 -2
View File
@@ -21,7 +21,7 @@ const __dirname = path.dirname(__filename);
// This is a stripped-down version of the CliArgs interface from config.ts
// to avoid circular dependencies.
interface SandboxCliArgs {
sandbox?: boolean | string;
sandbox?: boolean | string | null;
}
const VALID_SANDBOX_COMMANDS: ReadonlyArray<SandboxConfig['command']> = [
'docker',
@@ -34,7 +34,7 @@ function isSandboxCommand(value: string): value is SandboxConfig['command'] {
}
function getSandboxCommand(
sandbox?: boolean | string,
sandbox?: boolean | string | null,
): SandboxConfig['command'] | '' {
// If the SANDBOX env var is set, we're already inside the sandbox.
if (process.env['SANDBOX']) {
@@ -0,0 +1,26 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { SettingPaths } from './settingPaths.js';
describe('SettingPaths', () => {
it('should have the correct structure', () => {
expect(SettingPaths).toEqual({
General: {
PreferredEditor: 'general.preferredEditor',
},
});
});
it('should be immutable', () => {
expect(Object.isFrozen(SettingPaths)).toBe(false); // It's not frozen by default in JS unless Object.freeze is called, but it's `as const` in TS.
// However, we can check if the values are correct.
expect(SettingPaths.General.PreferredEditor).toBe(
'general.preferredEditor',
);
});
});
+204 -324
View File
@@ -157,107 +157,53 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged).toEqual({});
});
it('should load system settings if only system file exists', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === getSystemSettingsPath(),
);
const systemSettingsContent = {
ui: {
theme: 'system-default',
it.each([
{
scope: 'system',
path: getSystemSettingsPath(),
content: {
ui: { theme: 'system-default' },
tools: { sandbox: false },
},
tools: {
sandbox: false,
},
{
scope: 'user',
path: USER_SETTINGS_PATH,
content: {
ui: { theme: 'dark' },
context: { fileName: 'USER_CONTEXT.md' },
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === getSystemSettingsPath())
return JSON.stringify(systemSettingsContent);
return '{}';
},
{
scope: 'workspace',
path: MOCK_WORKSPACE_SETTINGS_PATH,
content: {
tools: { sandbox: true },
context: { fileName: 'WORKSPACE_CONTEXT.md' },
},
);
},
])(
'should load $scope settings if only $scope file exists',
({ scope, path, content }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
getSystemSettingsPath(),
'utf-8',
);
expect(settings.system.settings).toEqual(systemSettingsContent);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({
...systemSettingsContent,
});
});
it('should load user settings if only user file exists', () => {
const expectedUserSettingsPath = USER_SETTINGS_PATH; // Use the path actually resolved by the (mocked) module
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === expectedUserSettingsPath,
);
const userSettingsContent = {
ui: {
theme: 'dark',
},
context: {
fileName: 'USER_CONTEXT.md',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === expectedUserSettingsPath)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
expectedUserSettingsPath,
'utf-8',
);
expect(settings.user.settings).toEqual(userSettingsContent);
expect(settings.workspace.settings).toEqual({});
expect(settings.merged).toEqual({
...userSettingsContent,
});
});
it('should load workspace settings if only workspace file exists', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
tools: {
sandbox: true,
},
context: {
fileName: 'WORKSPACE_CONTEXT.md',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(fs.readFileSync).toHaveBeenCalledWith(
MOCK_WORKSPACE_SETTINGS_PATH,
'utf-8',
);
expect(settings.user.settings).toEqual({});
expect(settings.workspace.settings).toEqual(workspaceSettingsContent);
expect(settings.merged).toEqual({
...workspaceSettingsContent,
});
});
expect(fs.readFileSync).toHaveBeenCalledWith(path, 'utf-8');
expect(
settings[scope as 'system' | 'user' | 'workspace'].settings,
).toEqual(content);
expect(settings.merged).toEqual(content);
},
);
it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
(mockFsExistsSync as Mock).mockImplementation(
@@ -662,88 +608,63 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.security?.disableYoloMode).toBe(true); // System setting should be used
});
it('should handle contextFileName correctly when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = { context: { fileName: 'CUSTOM.md' } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '';
it.each([
{
description: 'contextFileName in user settings',
path: USER_SETTINGS_PATH,
content: { context: { fileName: 'CUSTOM.md' } },
expected: { key: 'context.fileName', value: 'CUSTOM.md' },
},
{
description: 'contextFileName in workspace settings',
path: MOCK_WORKSPACE_SETTINGS_PATH,
content: { context: { fileName: 'PROJECT_SPECIFIC.md' } },
expected: { key: 'context.fileName', value: 'PROJECT_SPECIFIC.md' },
},
{
description: 'excludedProjectEnvVars in user settings',
path: USER_SETTINGS_PATH,
content: {
advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'] },
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.context?.fileName).toBe('CUSTOM.md');
});
it('should handle contextFileName correctly when only in workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
context: { fileName: 'PROJECT_SPECIFIC.md' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
expected: {
key: 'advanced.excludedEnvVars',
value: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'],
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.context?.fileName).toBe('PROJECT_SPECIFIC.md');
});
it('should handle excludedProjectEnvVars correctly when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
general: {},
advanced: { excludedEnvVars: ['DEBUG', 'NODE_ENV', 'CUSTOM_VAR'] },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '';
},
{
description: 'excludedProjectEnvVars in workspace settings',
path: MOCK_WORKSPACE_SETTINGS_PATH,
content: {
advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] },
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.advanced?.excludedEnvVars).toEqual([
'DEBUG',
'NODE_ENV',
'CUSTOM_VAR',
]);
});
it('should handle excludedProjectEnvVars correctly when only in workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
general: {},
advanced: { excludedEnvVars: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'] },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
expected: {
key: 'advanced.excludedEnvVars',
value: ['WORKSPACE_DEBUG', 'WORKSPACE_VAR'],
},
);
},
])(
'should handle $description correctly',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.advanced?.excludedEnvVars).toEqual([
'WORKSPACE_DEBUG',
'WORKSPACE_VAR',
]);
});
const settings = loadSettings(MOCK_WORKSPACE_DIR);
const keys = expected.key.split('.');
let result: unknown = settings.merged;
for (const key of keys) {
result = (result as { [key: string]: unknown })[key];
}
expect(result).toEqual(expected.value);
},
);
it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => {
(mockFsExistsSync as Mock).mockImplementation(
@@ -810,37 +731,35 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.context?.fileName).toBeUndefined();
});
it('should load telemetry setting from user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = { telemetry: { enabled: true } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry?.enabled).toBe(true);
});
it('should load telemetry setting from workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = { telemetry: { enabled: false } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry?.enabled).toBe(false);
});
it.each([
{
scope: 'user',
path: USER_SETTINGS_PATH,
content: { telemetry: { enabled: true } },
expected: true,
},
{
scope: 'workspace',
path: MOCK_WORKSPACE_SETTINGS_PATH,
content: { telemetry: { enabled: false } },
expected: false,
},
])(
'should load telemetry setting from $scope settings',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry?.enabled).toBe(expected);
},
);
it('should prioritize workspace telemetry setting over user setting', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
@@ -932,63 +851,60 @@ describe('Settings Loading and Merging', () => {
});
});
it('should handle MCP servers when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
mcpServers: {
it.each([
{
scope: 'user',
path: USER_SETTINGS_PATH,
content: {
mcpServers: {
'user-only-server': {
command: 'user-only-command',
description: 'User only server',
},
},
},
expected: {
'user-only-server': {
command: 'user-only-command',
description: 'User only server',
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '';
},
{
scope: 'workspace',
path: MOCK_WORKSPACE_SETTINGS_PATH,
content: {
mcpServers: {
'workspace-only-server': {
command: 'workspace-only-command',
description: 'Workspace only server',
},
},
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.mcpServers).toEqual({
'user-only-server': {
command: 'user-only-command',
description: 'User only server',
},
});
});
it('should handle MCP servers when only in workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = {
mcpServers: {
expected: {
'workspace-only-server': {
command: 'workspace-only-command',
description: 'Workspace only server',
},
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '';
},
);
},
])(
'should handle MCP servers when only in $scope settings',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === path,
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === path) return JSON.stringify(content);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.mcpServers).toEqual({
'workspace-only-server': {
command: 'workspace-only-command',
description: 'Workspace only server',
},
});
});
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.mcpServers).toEqual(expected);
},
);
it('should have mcpServers as undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
@@ -1104,85 +1020,49 @@ describe('Settings Loading and Merging', () => {
});
});
it('should merge compressionThreshold settings, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
general: {},
model: { compressionThreshold: 0.5 },
};
const workspaceSettingsContent = {
general: {},
model: { compressionThreshold: 0.8 },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
describe('compressionThreshold settings', () => {
it.each([
{
description:
'should be taken from user settings if only present there',
userContent: { model: { compressionThreshold: 0.5 } },
workspaceContent: {},
expected: 0.5,
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.user.settings.model?.compressionThreshold).toEqual(0.5);
expect(settings.workspace.settings.model?.compressionThreshold).toEqual(
0.8,
);
expect(settings.merged.model?.compressionThreshold).toEqual(0.8);
});
it('should merge output format settings, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
output: { format: 'text' },
};
const workspaceSettingsContent = {
output: { format: 'json' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettingsContent);
return '{}';
{
description:
'should be taken from workspace settings if only present there',
userContent: {},
workspaceContent: { model: { compressionThreshold: 0.8 } },
expected: 0.8,
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.output?.format).toBe('json');
});
it('should handle compressionThreshold when only in user settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = {
general: {},
model: { compressionThreshold: 0.5 },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
{
description:
'should prioritize workspace settings over user settings',
userContent: { model: { compressionThreshold: 0.5 } },
workspaceContent: { model: { compressionThreshold: 0.8 } },
expected: 0.8,
},
);
{
description: 'should be undefined if not in any settings file',
userContent: {},
workspaceContent: {},
expected: undefined,
},
])('$description', ({ userContent, workspaceContent, expected }) => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH) return JSON.stringify(userContent);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceContent);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model?.compressionThreshold).toEqual(0.5);
});
it('should have model as undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
(fs.readFileSync as Mock).mockReturnValue('{}');
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model).toBeUndefined();
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.model?.compressionThreshold).toEqual(expected);
});
});
it('should use user compressionThreshold if workspace does not define it', () => {
+13 -13
View File
@@ -346,19 +346,19 @@ describe('SettingsSchema', () => {
).toBe('Enable preview features (e.g., preview models).');
});
it('should have useModelRouter setting in schema', () => {
expect(
getSettingsSchema().experimental.properties.useModelRouter,
).toBeDefined();
expect(
getSettingsSchema().experimental.properties.useModelRouter.type,
).toBe('boolean');
expect(
getSettingsSchema().experimental.properties.useModelRouter.category,
).toBe('Experimental');
expect(
getSettingsSchema().experimental.properties.useModelRouter.default,
).toBe(true);
it('should have isModelAvailabilityServiceEnabled setting in schema', () => {
const setting =
getSettingsSchema().experimental.properties
.isModelAvailabilityServiceEnabled;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(false);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(false);
expect(setting.description).toBe(
'Enable model routing using new availability service.',
);
});
});
+16 -7
View File
@@ -516,7 +516,7 @@ const SETTINGS_SCHEMA = {
label: 'Use Alternate Screen Buffer',
category: 'UI',
requiresRestart: true,
default: true,
default: false,
description:
'Use an alternate screen buffer for the UI, preserving shell history.',
showInDialog: true,
@@ -729,6 +729,16 @@ const SETTINGS_SCHEMA = {
'Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.',
showInDialog: false,
},
customAliases: {
type: 'object',
label: 'Custom Model Config Aliases',
category: 'Model',
requiresRestart: false,
default: {},
description:
'Custom named presets for model configs. These are merged with (and override) the built-in aliases.',
showInDialog: false,
},
overrides: {
type: 'array',
label: 'Model Config Overrides',
@@ -1281,15 +1291,14 @@ const SETTINGS_SCHEMA = {
'Enables extension loading/unloading within the CLI session.',
showInDialog: false,
},
useModelRouter: {
isModelAvailabilityServiceEnabled: {
type: 'boolean',
label: 'Use Model Router',
label: 'Enable Model Availability Service',
category: 'Experimental',
requiresRestart: true,
default: true,
description:
'Enable model routing to route requests to the best model based on complexity.',
showInDialog: true,
default: false,
description: 'Enable model routing using new availability service.',
showInDialog: false,
},
codebaseInvestigatorSettings: {
type: 'object',
+56
View File
@@ -0,0 +1,56 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { performInitialAuth } from './auth.js';
import { type Config } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', () => ({
AuthType: {
OAUTH: 'oauth',
},
getErrorMessage: (e: unknown) => (e as Error).message,
}));
const AuthType = {
OAUTH: 'oauth',
} as const;
describe('auth', () => {
let mockConfig: Config;
beforeEach(() => {
mockConfig = {
refreshAuth: vi.fn(),
} as unknown as Config;
});
it('should return null if authType is undefined', async () => {
const result = await performInitialAuth(mockConfig, undefined);
expect(result).toBeNull();
expect(mockConfig.refreshAuth).not.toHaveBeenCalled();
});
it('should return null on successful auth', async () => {
const result = await performInitialAuth(
mockConfig,
AuthType.OAUTH as unknown as Parameters<typeof performInitialAuth>[1],
);
expect(result).toBeNull();
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH);
});
it('should return error message on failed auth', async () => {
const error = new Error('Auth failed');
vi.mocked(mockConfig.refreshAuth).mockRejectedValue(error);
const result = await performInitialAuth(
mockConfig,
AuthType.OAUTH as unknown as Parameters<typeof performInitialAuth>[1],
);
expect(result).toBe('Failed to login. Message: Auth failed');
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.OAUTH);
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { initializeApp } from './initializer.js';
import {
IdeClient,
logIdeConnection,
logCliConfiguration,
type Config,
} from '@google/gemini-cli-core';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import { type LoadedSettings } from '../config/settings.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
IdeClient: {
getInstance: vi.fn(),
},
logIdeConnection: vi.fn(),
logCliConfiguration: vi.fn(),
StartSessionEvent: vi.fn(),
IdeConnectionEvent: vi.fn(),
};
});
vi.mock('./auth.js', () => ({
performInitialAuth: vi.fn(),
}));
vi.mock('./theme.js', () => ({
validateTheme: vi.fn(),
}));
describe('initializer', () => {
let mockConfig: {
getToolRegistry: ReturnType<typeof vi.fn>;
getIdeMode: ReturnType<typeof vi.fn>;
getGeminiMdFileCount: ReturnType<typeof vi.fn>;
};
let mockSettings: LoadedSettings;
let mockIdeClient: {
connect: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.clearAllMocks();
mockConfig = {
getToolRegistry: vi.fn(),
getIdeMode: vi.fn().mockReturnValue(false),
getGeminiMdFileCount: vi.fn().mockReturnValue(5),
};
mockSettings = {
merged: {
security: {
auth: {
selectedType: 'oauth',
},
},
},
} as unknown as LoadedSettings;
mockIdeClient = {
connect: vi.fn(),
};
vi.mocked(IdeClient.getInstance).mockResolvedValue(
mockIdeClient as unknown as IdeClient,
);
vi.mocked(performInitialAuth).mockResolvedValue(null);
vi.mocked(validateTheme).mockReturnValue(null);
});
it('should initialize correctly in non-IDE mode', async () => {
const result = await initializeApp(
mockConfig as unknown as Config,
mockSettings,
);
expect(result).toEqual({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 5,
});
expect(performInitialAuth).toHaveBeenCalledWith(mockConfig, 'oauth');
expect(validateTheme).toHaveBeenCalledWith(mockSettings);
expect(logCliConfiguration).toHaveBeenCalled();
expect(IdeClient.getInstance).not.toHaveBeenCalled();
});
it('should initialize correctly in IDE mode', async () => {
mockConfig.getIdeMode.mockReturnValue(true);
const result = await initializeApp(
mockConfig as unknown as Config,
mockSettings,
);
expect(result).toEqual({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
geminiMdFileCount: 5,
});
expect(IdeClient.getInstance).toHaveBeenCalled();
expect(mockIdeClient.connect).toHaveBeenCalled();
expect(logIdeConnection).toHaveBeenCalledWith(
mockConfig as unknown as Config,
expect.any(Object),
);
});
it('should handle auth error', async () => {
vi.mocked(performInitialAuth).mockResolvedValue('Auth failed');
const result = await initializeApp(
mockConfig as unknown as Config,
mockSettings,
);
expect(result.authError).toBe('Auth failed');
expect(result.shouldOpenAuthDialog).toBe(true);
});
it('should handle undefined auth type', async () => {
mockSettings.merged.security!.auth!.selectedType = undefined;
const result = await initializeApp(
mockConfig as unknown as Config,
mockSettings,
);
expect(result.shouldOpenAuthDialog).toBe(true);
});
it('should handle theme error', async () => {
vi.mocked(validateTheme).mockReturnValue('Theme not found');
const result = await initializeApp(
mockConfig as unknown as Config,
mockSettings,
);
expect(result.themeError).toBe('Theme not found');
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { validateTheme } from './theme.js';
import { themeManager } from '../ui/themes/theme-manager.js';
import { type LoadedSettings } from '../config/settings.js';
vi.mock('../ui/themes/theme-manager.js', () => ({
themeManager: {
findThemeByName: vi.fn(),
},
}));
describe('theme', () => {
let mockSettings: LoadedSettings;
beforeEach(() => {
vi.clearAllMocks();
mockSettings = {
merged: {
ui: {
theme: 'test-theme',
},
},
} as unknown as LoadedSettings;
});
it('should return null if theme is found', () => {
vi.mocked(themeManager.findThemeByName).mockReturnValue(
{} as unknown as ReturnType<typeof themeManager.findThemeByName>,
);
const result = validateTheme(mockSettings);
expect(result).toBeNull();
expect(themeManager.findThemeByName).toHaveBeenCalledWith('test-theme');
});
it('should return error message if theme is not found', () => {
vi.mocked(themeManager.findThemeByName).mockReturnValue(undefined);
const result = validateTheme(mockSettings);
expect(result).toBe('Theme "test-theme" not found.');
expect(themeManager.findThemeByName).toHaveBeenCalledWith('test-theme');
});
it('should return null if theme is undefined', () => {
mockSettings.merged.ui!.theme = undefined;
const result = validateTheme(mockSettings);
expect(result).toBeNull();
expect(themeManager.findThemeByName).not.toHaveBeenCalled();
});
});
+752 -29
View File
@@ -18,7 +18,10 @@ import {
setupUnhandledRejectionHandler,
validateDnsResolutionOrder,
startInteractiveUI,
getNodeMemoryArgs,
} from './gemini.js';
import os from 'node:os';
import v8 from 'node:v8';
import { type LoadedSettings } from './config/settings.js';
import { appEvents, AppEvent } from './utils/events.js';
import {
@@ -40,6 +43,32 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...actual,
recordSlowRender: vi.fn(),
writeToStdout: vi.fn((...args) =>
process.stdout.write(
...(args as Parameters<typeof process.stdout.write>),
),
),
patchStdio: vi.fn(() => () => {}),
createInkStdio: vi.fn(() => ({
stdout: {
write: vi.fn((...args) =>
process.stdout.write(
...(args as Parameters<typeof process.stdout.write>),
),
),
columns: 80,
rows: 24,
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
write: vi.fn(),
},
})),
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
enterAlternateScreen: vi.fn(),
disableLineWrapping: vi.fn(),
};
});
@@ -136,6 +165,7 @@ vi.mock('./utils/sandbox.js', () => ({
vi.mock('./utils/relaunch.js', () => ({
relaunchAppInChildProcess: vi.fn(),
relaunchOnExitCode: vi.fn(),
}));
vi.mock('./config/sandboxConfig.js', () => ({
@@ -149,35 +179,6 @@ vi.mock('./ui/utils/mouse.js', () => ({
isIncompleteMouseSequence: vi.fn(),
}));
vi.mock('./utils/stdio.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils/stdio.js')>();
return {
...actual,
writeToStdout: vi.fn((...args) =>
process.stdout.write(
...(args as Parameters<typeof process.stdout.write>),
),
),
patchStdio: vi.fn(() => () => {}),
createInkStdio: vi.fn(() => ({
stdout: {
write: vi.fn((...args) =>
process.stdout.write(
...(args as Parameters<typeof process.stdout.write>),
),
),
columns: 80,
rows: 24,
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: {
write: vi.fn(),
},
})),
};
});
describe('gemini.tsx main function', () => {
let originalEnvGeminiSandbox: string | undefined;
let originalEnvSandbox: string | undefined;
@@ -342,6 +343,88 @@ describe('gemini.tsx main function', () => {
});
});
describe('setWindowTitle', () => {
it('should set window title when hideWindowTitle is false', async () => {
// setWindowTitle is not exported, but we can test its effect if we had a way to call it.
// Since we can't easily call it directly without exporting it, we skip direct testing
// and rely on startInteractiveUI tests which call it.
});
});
describe('initializeOutputListenersAndFlush', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should flush backlogs and setup listeners if no listeners exist', async () => {
const { coreEvents } = await import('@google/gemini-cli-core');
const { initializeOutputListenersAndFlush } = await import('./gemini.js');
// Mock listenerCount to return 0
vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0);
const drainSpy = vi.spyOn(coreEvents, 'drainBacklogs');
initializeOutputListenersAndFlush();
expect(drainSpy).toHaveBeenCalled();
// We can't easily check if listeners were added without access to the internal state of coreEvents,
// but we can verify that drainBacklogs was called.
});
});
describe('getNodeMemoryArgs', () => {
let osTotalMemSpy: MockInstance;
let v8GetHeapStatisticsSpy: MockInstance;
beforeEach(() => {
osTotalMemSpy = vi.spyOn(os, 'totalmem');
v8GetHeapStatisticsSpy = vi.spyOn(v8, 'getHeapStatistics');
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return empty array if GEMINI_CLI_NO_RELAUNCH is set', () => {
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
expect(getNodeMemoryArgs(false)).toEqual([]);
});
it('should return empty array if current heap limit is sufficient', () => {
osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); // 16GB
v8GetHeapStatisticsSpy.mockReturnValue({
heap_size_limit: 8 * 1024 * 1024 * 1024, // 8GB
});
// Target is 50% of 16GB = 8GB. Current is 8GB. No relaunch needed.
expect(getNodeMemoryArgs(false)).toEqual([]);
});
it('should return memory args if current heap limit is insufficient', () => {
osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); // 16GB
v8GetHeapStatisticsSpy.mockReturnValue({
heap_size_limit: 4 * 1024 * 1024 * 1024, // 4GB
});
// Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed.
expect(getNodeMemoryArgs(false)).toEqual(['--max-old-space-size=8192']);
});
it('should log debug info when isDebugMode is true', () => {
const debugSpy = vi.spyOn(debugLogger, 'debug');
osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024);
v8GetHeapStatisticsSpy.mockReturnValue({
heap_size_limit: 4 * 1024 * 1024 * 1024,
});
getNodeMemoryArgs(true);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining('Current heap size'),
);
expect(debugSpy).toHaveBeenCalledWith(
expect.stringContaining('Need to relaunch with more memory'),
);
});
});
describe('gemini.tsx main function kitty protocol', () => {
let originalEnvNoRelaunch: string | undefined;
let setRawModeSpy: MockInstance<
@@ -463,6 +546,618 @@ describe('gemini.tsx main function kitty protocol', () => {
expect(setRawModeSpy).toHaveBeenCalledWith(true);
expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1);
});
it.each([
{ flag: 'listExtensions' },
{ flag: 'listSessions' },
{ flag: 'deleteSession', value: 'session-id' },
])('should handle --$flag flag', async ({ flag, value }) => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { listSessions, deleteSession } = await import('./utils/sessions.js');
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
const mockConfig = {
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getListExtensions: () => flag === 'listExtensions',
getListSessions: () => flag === 'listSessions',
getDeleteSession: () => (flag === 'deleteSession' ? value : undefined),
getExtensions: () => [{ name: 'ext1' }],
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
initialize: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
} as unknown as Config;
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
vi.mock('./utils/sessions.js', () => ({
listSessions: vi.fn(),
deleteSession: vi.fn(),
}));
const debugLoggerLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
if (flag === 'listExtensions') {
expect(debugLoggerLogSpy).toHaveBeenCalledWith(
expect.stringContaining('ext1'),
);
} else if (flag === 'listSessions') {
expect(listSessions).toHaveBeenCalledWith(mockConfig);
} else if (flag === 'deleteSession') {
expect(deleteSession).toHaveBeenCalledWith(mockConfig, value);
}
expect(processExitSpy).toHaveBeenCalledWith(0);
processExitSpy.mockRestore();
});
it('should handle sandbox activation', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const { start_sandbox } = await import('./utils/sandbox.js');
const { relaunchOnExitCode } = await import('./utils/relaunch.js');
const { loadSettings } = await import('./config/settings.js');
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadSettings).mockReturnValue({
merged: {
advanced: {},
security: { auth: {} },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
const mockConfig = {
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => true,
getDebugMode: () => false,
getListExtensions: () => false,
getListSessions: () => false,
getDeleteSession: () => undefined,
getExtensions: () => [],
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
initialize: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
refreshAuth: vi.fn(),
} as unknown as Config;
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(relaunchOnExitCode).mockImplementation(async (fn) => {
await fn();
});
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(start_sandbox).toHaveBeenCalled();
expect(processExitSpy).toHaveBeenCalledWith(0);
processExitSpy.mockRestore();
});
it('should exit with error when --prompt-interactive is used with piped input', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const core = await import('@google/gemini-cli-core');
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const writeToStderrSpy = vi
.spyOn(core, 'writeToStderr')
.mockImplementation(() => true);
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: true,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => false,
getQuestion: () => '',
getSandbox: () => false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
// Mock stdin to be non-TTY
Object.defineProperty(process.stdin, 'isTTY', {
value: false,
configurable: true,
});
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(writeToStderrSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Error: The --prompt-interactive flag cannot be used',
),
);
expect(processExitSpy).toHaveBeenCalledWith(1);
processExitSpy.mockRestore();
writeToStderrSpy.mockRestore();
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
}); // Restore TTY
});
it('should log warning when theme is not found', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { themeManager } = await import('./ui/themes/theme-manager.js');
const debugLoggerWarnSpy = vi
.spyOn(debugLogger, 'warn')
.mockImplementation(() => {});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: {
advanced: {},
security: { auth: {} },
ui: { theme: 'non-existent-theme' },
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => false,
getQuestion: () => 'test',
getSandbox: () => false,
getDebugMode: () => false,
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
initialize: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getListExtensions: () => false,
getListSessions: () => false,
getDeleteSession: () => undefined,
getToolRegistry: vi.fn(),
getExtensions: () => [],
getModel: () => 'gemini-pro',
getEmbeddingModel: () => 'embedding-001',
getApprovalMode: () => 'default',
getCoreTools: () => [],
getTelemetryEnabled: () => false,
getTelemetryLogPromptsEnabled: () => false,
getFileFilteringRespectGitIgnore: () => true,
getOutputFormat: () => 'text',
getUsageStatisticsEnabled: () => false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false);
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(debugLoggerWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Warning: Theme "non-existent-theme" not found.'),
);
processExitSpy.mockRestore();
});
it('should handle session selector error', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
vi.mock('./utils/sessionUtils.js', () => ({
SessionSelector: class {
resolveSession = vi
.fn()
.mockRejectedValue(new Error('Session not found'));
},
}));
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
resume: 'session-id',
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => true,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
initialize: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getListExtensions: () => false,
getListSessions: () => false,
getDeleteSession: () => undefined,
getToolRegistry: vi.fn(),
getExtensions: () => [],
getModel: () => 'gemini-pro',
getEmbeddingModel: () => 'embedding-001',
getApprovalMode: () => 'default',
getCoreTools: () => [],
getTelemetryEnabled: () => false,
getTelemetryLogPromptsEnabled: () => false,
getFileFilteringRespectGitIgnore: () => true,
getOutputFormat: () => 'text',
getUsageStatisticsEnabled: () => false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Error resuming session: Session not found'),
);
expect(processExitSpy).toHaveBeenCalledWith(1);
processExitSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
it.skip('should log error when cleanupExpiredSessions fails', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { cleanupExpiredSessions } = await import(
'./utils/sessionCleanup.js'
);
vi.mocked(cleanupExpiredSessions).mockRejectedValue(
new Error('Cleanup failed'),
);
const debugLoggerErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => false,
getQuestion: () => 'test',
getSandbox: () => false,
getDebugMode: () => false,
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
initialize: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getListExtensions: () => false,
getListSessions: () => false,
getDeleteSession: () => undefined,
getToolRegistry: vi.fn(),
getExtensions: () => [],
getModel: () => 'gemini-pro',
getEmbeddingModel: () => 'embedding-001',
getApprovalMode: () => 'default',
getCoreTools: () => [],
getTelemetryEnabled: () => false,
getTelemetryLogPromptsEnabled: () => false,
getFileFilteringRespectGitIgnore: () => true,
getOutputFormat: () => 'text',
getUsageStatisticsEnabled: () => false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
// The mock is already set up at the top of the test
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to cleanup expired sessions: Cleanup failed',
),
);
expect(processExitSpy).toHaveBeenCalledWith(0); // Should not exit on cleanup failure
processExitSpy.mockRestore();
});
it('should handle refreshAuth failure', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { loadSandboxConfig } = await import('./config/sandboxConfig.js');
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const debugLoggerErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
vi.mocked(loadSettings).mockReturnValue({
merged: {
advanced: {},
security: { auth: { selectedType: 'google' } },
ui: {},
},
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadSandboxConfig).mockResolvedValue({} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => true,
getQuestion: () => '',
getSandbox: () => false,
getDebugMode: () => false,
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
initialize: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getListExtensions: () => false,
getListSessions: () => false,
getDeleteSession: () => undefined,
getToolRegistry: vi.fn(),
getExtensions: () => [],
getModel: () => 'gemini-pro',
getEmbeddingModel: () => 'embedding-001',
getApprovalMode: () => 'default',
getCoreTools: () => [],
getTelemetryEnabled: () => false,
getTelemetryLogPromptsEnabled: () => false,
getFileFilteringRespectGitIgnore: () => true,
getOutputFormat: () => 'text',
getUsageStatisticsEnabled: () => false,
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth refresh failed')),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
'Error authenticating:',
expect.any(Error),
);
expect(processExitSpy).toHaveBeenCalledWith(1);
processExitSpy.mockRestore();
});
it('should read from stdin in non-interactive mode', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const { readStdin } = await import('./utils/readStdin.js');
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => false,
getQuestion: () => 'test-question',
getSandbox: () => false,
getDebugMode: () => false,
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
initialize: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getGeminiMdFileCount: () => 0,
getProjectRoot: () => '/',
getListExtensions: () => false,
getListSessions: () => false,
getDeleteSession: () => undefined,
getToolRegistry: vi.fn(),
getExtensions: () => [],
getModel: () => 'gemini-pro',
getEmbeddingModel: () => 'embedding-001',
getApprovalMode: () => 'default',
getCoreTools: () => [],
getTelemetryEnabled: () => false,
getTelemetryLogPromptsEnabled: () => false,
getFileFilteringRespectGitIgnore: () => true,
getOutputFormat: () => 'text',
getUsageStatisticsEnabled: () => false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mock('./utils/readStdin.js', () => ({
readStdin: vi.fn().mockResolvedValue('stdin-data'),
}));
const runNonInteractiveSpy = vi.hoisted(() => vi.fn());
vi.mock('./nonInteractiveCli.js', () => ({
runNonInteractive: runNonInteractiveSpy,
}));
runNonInteractiveSpy.mockClear();
vi.mock('./validateNonInterActiveAuth.js', () => ({
validateNonInteractiveAuth: vi.fn().mockResolvedValue({}),
}));
// Mock stdin to be non-TTY
Object.defineProperty(process.stdin, 'isTTY', {
value: false,
configurable: true,
});
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(readStdin).toHaveBeenCalled();
// In this test setup, runNonInteractive might be called on the mocked module,
// but we need to ensure we are checking the correct spy instance.
// Since vi.mock is hoisted, runNonInteractiveSpy is defined early.
expect(runNonInteractiveSpy).toHaveBeenCalled();
const callArgs = runNonInteractiveSpy.mock.calls[0][0];
expect(callArgs.input).toBe('test-question');
expect(processExitSpy).toHaveBeenCalledWith(0);
processExitSpy.mockRestore();
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
});
});
describe('validateDnsResolutionOrder', () => {
@@ -512,6 +1207,7 @@ describe('startInteractiveUI', () => {
merged: {
ui: {
hideWindowTitle: false,
useAlternateBuffer: true,
},
},
} as LoadedSettings;
@@ -601,6 +1297,33 @@ describe('startInteractiveUI', () => {
expect(reactElement).toBeDefined();
});
it('should enable mouse events when alternate buffer is enabled', async () => {
const { enableMouseEvents } = await import('@google/gemini-cli-core');
await startTestInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
undefined,
mockInitializationResult,
);
expect(enableMouseEvents).toHaveBeenCalled();
});
it('should patch console', async () => {
const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
const patchSpy = vi.spyOn(ConsolePatcher.prototype, 'patch');
await startTestInteractiveUI(
mockConfig,
mockSettings,
mockStartupWarnings,
mockWorkspaceRoot,
undefined,
mockInitializationResult,
);
expect(patchSpy).toHaveBeenCalled();
});
it('should perform all startup tasks in correct order', async () => {
const { getCliVersion } = await import('./utils/version.js');
const { checkForUpdates } = await import('./ui/utils/updateCheck.js');
+32 -21
View File
@@ -33,13 +33,11 @@ import {
runExitCleanup,
} from './utils/cleanup.js';
import { getCliVersion } from './utils/version.js';
import type {
Config,
ResumedSessionData,
OutputPayload,
ConsoleLogPayload,
} from '@google/gemini-cli-core';
import {
type Config,
type ResumedSessionData,
type OutputPayload,
type ConsoleLogPayload,
sessionId,
logUserPrompt,
AuthType,
@@ -49,6 +47,15 @@ import {
recordSlowRender,
coreEvents,
CoreEvent,
createInkStdio,
patchStdio,
writeToStdout,
writeToStderr,
disableMouseEvents,
enableMouseEvents,
enterAlternateScreen,
disableLineWrapping,
shouldEnterAlternateScreen,
} from '@google/gemini-cli-core';
import {
initializeApp,
@@ -81,16 +88,8 @@ import { deleteSession, listSessions } from './utils/sessions.js';
import { ExtensionManager } from './config/extension-manager.js';
import { createPolicyUpdater } from './config/policy.js';
import { requestConsentNonInteractive } from './config/extensions/consent.js';
import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import ansiEscapes from 'ansi-escapes';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import {
createInkStdio,
patchStdio,
writeToStderr,
writeToStdout,
} from './utils/stdio.js';
import { profiler } from './ui/components/DebugProfiler.js';
@@ -113,7 +112,7 @@ export function validateDnsResolutionOrder(
return defaultValue;
}
function getNodeMemoryArgs(isDebugMode: boolean): string[] {
export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
const totalMemoryMB = os.totalmem() / (1024 * 1024);
const heapStats = v8.getHeapStatistics();
const currentMaxOldSpaceSizeMb = Math.floor(
@@ -178,8 +177,10 @@ export async function startInteractiveUI(
// as there is no benefit of alternate buffer mode when using a screen reader
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer =
isAlternateBufferEnabled(settings) && !config.getScreenReader();
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(settings),
config.getScreenReader(),
);
const mouseEventsEnabled = useAlternateBuffer;
if (mouseEventsEnabled) {
enableMouseEvents();
@@ -451,7 +452,11 @@ export async function main() {
createPolicyUpdater(policyEngine, messageBus);
// Cleanup sessions after config initialization
await cleanupExpiredSessions(config, settings.merged);
try {
await cleanupExpiredSessions(config, settings.merged);
} catch (e) {
debugLogger.error('Failed to cleanup expired sessions:', e);
}
if (config.getListExtensions()) {
debugLogger.log('Installed extensions:');
@@ -483,8 +488,14 @@ export async function main() {
// input showing up in the output.
process.stdin.setRawMode(true);
if (isAlternateBufferEnabled(settings)) {
writeToStdout(ansiEscapes.enterAlternativeScreen);
if (
shouldEnterAlternateScreen(
isAlternateBufferEnabled(settings),
config.getScreenReader(),
)
) {
enterAlternateScreen();
disableLineWrapping();
// Ink will cleanup so there is no need for us to manually cleanup.
}
@@ -627,7 +638,7 @@ function setWindowTitle(title: string, settings: LoadedSettings) {
}
}
function initializeOutputListenersAndFlush() {
export function initializeOutputListenersAndFlush() {
// If there are no listeners for output, make sure we flush so output is not
// lost.
if (coreEvents.listenerCount(CoreEvent.Output) === 0) {
+228
View File
@@ -0,0 +1,228 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { main } from './gemini.js';
import { debugLogger } from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
// Custom error to identify mock process.exit calls
class MockProcessExitError extends Error {
constructor(readonly code?: string | number | null | undefined) {
super('PROCESS_EXIT_MOCKED');
this.name = 'MockProcessExitError';
}
}
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
writeToStdout: vi.fn(),
patchStdio: vi.fn(() => () => {}),
createInkStdio: vi.fn(() => ({
stdout: {
write: vi.fn(),
columns: 80,
rows: 24,
on: vi.fn(),
removeListener: vi.fn(),
},
stderr: { write: vi.fn() },
})),
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
enterAlternateScreen: vi.fn(),
disableLineWrapping: vi.fn(),
};
});
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
render: vi.fn(() => ({
unmount: vi.fn(),
rerender: vi.fn(),
cleanup: vi.fn(),
waitUntilExit: vi.fn(),
})),
};
});
vi.mock('./config/settings.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./config/settings.js')>();
return {
...actual,
loadSettings: vi.fn().mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
}),
};
});
vi.mock('./config/config.js', () => ({
loadCliConfig: vi.fn().mockResolvedValue({
getSandbox: vi.fn(() => false),
getQuestion: vi.fn(() => ''),
isInteractive: () => false,
} as unknown as Config),
parseArguments: vi.fn().mockResolvedValue({}),
isDebugMode: vi.fn(() => false),
}));
vi.mock('read-package-up', () => ({
readPackageUp: vi.fn().mockResolvedValue({
packageJson: { name: 'test-pkg', version: 'test-version' },
path: '/fake/path/package.json',
}),
}));
vi.mock('update-notifier', () => ({
default: vi.fn(() => ({ notify: vi.fn() })),
}));
vi.mock('./utils/events.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils/events.js')>();
return { ...actual, appEvents: { emit: vi.fn() } };
});
vi.mock('./utils/sandbox.js', () => ({
sandbox_command: vi.fn(() => ''),
start_sandbox: vi.fn(() => Promise.resolve()),
}));
vi.mock('./utils/relaunch.js', () => ({
relaunchAppInChildProcess: vi.fn(),
relaunchOnExitCode: vi.fn(),
}));
vi.mock('./config/sandboxConfig.js', () => ({
loadSandboxConfig: vi.fn(),
}));
vi.mock('./ui/utils/mouse.js', () => ({
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
parseMouseEvent: vi.fn(),
isIncompleteMouseSequence: vi.fn(),
}));
vi.mock('./validateNonInterActiveAuth.js', () => ({
validateNonInteractiveAuth: vi.fn().mockResolvedValue({}),
}));
vi.mock('./nonInteractiveCli.js', () => ({
runNonInteractive: vi.fn().mockResolvedValue(undefined),
}));
const { cleanupMockState } = vi.hoisted(() => ({
cleanupMockState: { shouldThrow: false, called: false },
}));
// Mock sessionCleanup.js at the top level
vi.mock('./utils/sessionCleanup.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./utils/sessionCleanup.js')>();
return {
...actual,
cleanupExpiredSessions: async () => {
cleanupMockState.called = true;
if (cleanupMockState.shouldThrow) {
throw new Error('Cleanup failed');
}
},
};
});
describe('gemini.tsx main function cleanup', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
});
afterEach(() => {
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
vi.restoreAllMocks();
});
it('should log error when cleanupExpiredSessions fails', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
cleanupMockState.shouldThrow = true;
cleanupMockState.called = false;
const debugLoggerErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
vi.mocked(loadSettings).mockReturnValue({
merged: { advanced: {}, security: { auth: {} }, ui: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: vi.fn(() => false),
getQuestion: vi.fn(() => 'test'),
getSandbox: vi.fn(() => false),
getDebugMode: vi.fn(() => false),
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
initialize: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
getExperimentalZedIntegration: vi.fn(() => false),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
getListExtensions: vi.fn(() => false),
getListSessions: vi.fn(() => false),
getDeleteSession: vi.fn(() => undefined),
getToolRegistry: vi.fn(),
getExtensions: vi.fn(() => []),
getModel: vi.fn(() => 'gemini-pro'),
getEmbeddingModel: vi.fn(() => 'embedding-001'),
getApprovalMode: vi.fn(() => 'default'),
getCoreTools: vi.fn(() => []),
getTelemetryEnabled: vi.fn(() => false),
getTelemetryLogPromptsEnabled: vi.fn(() => false),
getFileFilteringRespectGitIgnore: vi.fn(() => true),
getOutputFormat: vi.fn(() => 'text'),
getUsageStatisticsEnabled: vi.fn(() => false),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(cleanupMockState.called).toBe(true);
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
'Failed to cleanup expired sessions:',
expect.objectContaining({ message: 'Cleanup failed' }),
);
expect(processExitSpy).toHaveBeenCalledWith(0); // Should not exit on cleanup failure
processExitSpy.mockRestore();
});
});
+441
View File
@@ -129,6 +129,7 @@ describe('runNonInteractive', () => {
processStdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
vi.spyOn(process.stdout, 'on').mockImplementation(() => process.stdout);
processStderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
@@ -167,6 +168,7 @@ describe('runNonInteractive', () => {
getContentGeneratorConfig: vi.fn().mockReturnValue({}),
getDebugMode: vi.fn().mockReturnValue(false),
getOutputFormat: vi.fn().mockReturnValue('text'),
getModel: vi.fn().mockReturnValue('test-model'),
getFolderTrust: vi.fn().mockReturnValue(false),
isTrustedFolder: vi.fn().mockReturnValue(false),
} as unknown as Config;
@@ -885,6 +887,168 @@ describe('runNonInteractive', () => {
expect(getWrittenOutput()).toBe('Response from command\n');
});
it('should handle slash commands', async () => {
const nonInteractiveCliCommands = await import(
'./nonInteractiveCliCommands.js'
);
const handleSlashCommandSpy = vi.spyOn(
nonInteractiveCliCommands,
'handleSlashCommand',
);
handleSlashCommandSpy.mockResolvedValue([{ text: 'Slash command output' }]);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Response to slash command' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: '/help',
prompt_id: 'prompt-id-slash',
});
expect(handleSlashCommandSpy).toHaveBeenCalledWith(
'/help',
expect.any(AbortController),
mockConfig,
mockSettings,
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith(
[{ text: 'Slash command output' }],
expect.any(AbortSignal),
'prompt-id-slash',
);
expect(getWrittenOutput()).toBe('Response to slash command\n');
handleSlashCommandSpy.mockRestore();
});
it('should handle cancellation (Ctrl+C)', async () => {
// Mock isTTY and setRawMode safely
const originalIsTTY = process.stdin.isTTY;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const originalSetRawMode = (process.stdin as any).setRawMode;
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
if (!originalSetRawMode) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).setRawMode = vi.fn();
}
const stdinOnSpy = vi
.spyOn(process.stdin, 'on')
.mockImplementation(() => process.stdin);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true);
vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin);
vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin);
vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation(
() => process.stdin,
);
// Spy on handleCancellationError to verify it's called
const errors = await import('./utils/errors.js');
const handleCancellationErrorSpy = vi
.spyOn(errors, 'handleCancellationError')
.mockImplementation(() => {
throw new Error('Cancelled');
});
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Thinking...' },
];
// Create a stream that responds to abortion
mockGeminiClient.sendMessageStream.mockImplementation(
(_messages, signal: AbortSignal) =>
(async function* () {
yield events[0];
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, 1000);
signal.addEventListener('abort', () => {
clearTimeout(timeout);
setTimeout(() => {
reject(new Error('Aborted')); // This will be caught by nonInteractiveCli and passed to handleError
}, 300);
});
});
})(),
);
const runPromise = runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Long running query',
prompt_id: 'prompt-id-cancel',
});
// Wait a bit for setup to complete and listeners to be registered
await new Promise((resolve) => setTimeout(resolve, 100));
// Find the keypress handler registered by runNonInteractive
const keypressCall = stdinOnSpy.mock.calls.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(call) => (call[0] as any) === 'keypress',
);
expect(keypressCall).toBeDefined();
const keypressHandler = keypressCall?.[1] as (
str: string,
key: { name?: string; ctrl?: boolean },
) => void;
if (keypressHandler) {
// Simulate Ctrl+C
keypressHandler('\u0003', { ctrl: true, name: 'c' });
}
// The promise should reject with 'Aborted' because our mock stream throws it,
// and nonInteractiveCli catches it and calls handleError, which doesn't necessarily throw.
// Wait, if handleError is called, we should check that.
// But here we want to check if Ctrl+C works.
// In our current setup, Ctrl+C aborts the signal. The stream throws 'Aborted'.
// nonInteractiveCli catches 'Aborted' and calls handleError.
// If we want to test that handleCancellationError is called, we need the loop to detect abortion.
// But our stream throws before the loop can detect it.
// Let's just check that the promise rejects with 'Aborted' for now,
// which proves the abortion signal reached the stream.
await expect(runPromise).rejects.toThrow('Aborted');
expect(
processStderrSpy.mock.calls.some(
(call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
),
).toBe(true);
handleCancellationErrorSpy.mockRestore();
// Restore original values
Object.defineProperty(process.stdin, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
if (originalSetRawMode) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(process.stdin as any).setRawMode = originalSetRawMode;
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (process.stdin as any).setRawMode;
}
// Spies are automatically restored by vi.restoreAllMocks() in afterEach,
// but we can also do it manually if needed.
});
it('should throw FatalInputError if a command requires confirmation', async () => {
const mockCommand = {
name: 'confirm',
@@ -1290,4 +1454,281 @@ describe('runNonInteractive', () => {
'The --prompt (-p) flag has been deprecated and will be removed in a future version. Please use a positional argument for your prompt. See gemini --help for more information.\n';
expect(processStderrSpy).toHaveBeenCalledWith(deprecateText);
});
it('should emit appropriate events for streaming JSON output', async () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
MOCK_SESSION_METRICS,
);
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'testTool',
args: { arg1: 'value1' },
isClientInitiated: false,
prompt_id: 'prompt-id-stream',
},
};
mockCoreExecuteToolCall.mockResolvedValue({
status: 'success',
request: toolCallEvent.value,
tool: {} as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
response: {
responseParts: [{ text: 'Tool response' }],
callId: 'tool-1',
error: undefined,
errorType: undefined,
contentLength: undefined,
resultDisplay: 'Tool executed successfully',
},
});
const firstCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Thinking...' },
toolCallEvent,
];
const secondCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Final answer' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } },
},
];
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Stream test',
prompt_id: 'prompt-id-stream',
});
const output = getWrittenOutput();
const sanitizedOutput = output
.replace(/"timestamp":"[^"]+"/g, '"timestamp":"<TIMESTAMP>"')
.replace(/"duration_ms":\d+/g, '"duration_ms":<DURATION>');
expect(sanitizedOutput).toMatchSnapshot();
});
it('should handle EPIPE error gracefully', async () => {
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Hello' },
{ type: GeminiEventType.Content, value: ' World' },
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
// Mock process.exit to track calls without throwing
vi.spyOn(process, 'exit').mockImplementation((_code) => undefined as never);
// Simulate EPIPE error on stdout
const stdoutErrorCallback = (process.stdout.on as Mock).mock.calls.find(
(call) => call[0] === 'error',
)?.[1];
if (stdoutErrorCallback) {
stdoutErrorCallback({ code: 'EPIPE' });
}
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'EPIPE test',
prompt_id: 'prompt-id-epipe',
});
// Since EPIPE is simulated, it might exit early or continue depending on timing,
// but our main goal is to verify the handler is registered and handles EPIPE.
expect(process.stdout.on).toHaveBeenCalledWith(
'error',
expect.any(Function),
);
});
it('should resume chat when resumedSessionData is provided', async () => {
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Resumed' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
const resumedSessionData = {
conversation: {
sessionId: 'resumed-session-id',
messages: [
{ role: 'user', parts: [{ text: 'Previous message' }] },
] as any, // eslint-disable-line @typescript-eslint/no-explicit-any
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
firstUserMessage: 'Previous message',
projectHash: 'test-hash',
},
filePath: '/path/to/session.json',
};
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Continue',
prompt_id: 'prompt-id-resume',
resumedSessionData,
});
expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith(
expect.any(Array),
resumedSessionData,
);
expect(getWrittenOutput()).toBe('Resumed\n');
});
it.each([
{
name: 'loop detected',
events: [
{ type: GeminiEventType.LoopDetected },
] as ServerGeminiStreamEvent[],
input: 'Loop test',
promptId: 'prompt-id-loop',
},
{
name: 'max session turns',
events: [
{ type: GeminiEventType.MaxSessionTurns },
] as ServerGeminiStreamEvent[],
input: 'Max turns test',
promptId: 'prompt-id-max-turns',
},
])(
'should emit appropriate error event in streaming JSON mode: $name',
async ({ events, input, promptId }) => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
MOCK_SESSION_METRICS,
);
const streamEvents: ServerGeminiStreamEvent[] = [
...events,
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } },
},
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(streamEvents),
);
try {
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input,
prompt_id: promptId,
});
} catch (_error) {
// Expected exit
}
const output = getWrittenOutput();
const sanitizedOutput = output
.replace(/"timestamp":"[^"]+"/g, '"timestamp":"<TIMESTAMP>"')
.replace(/"duration_ms":\d+/g, '"duration_ms":<DURATION>');
expect(sanitizedOutput).toMatchSnapshot();
},
);
it('should log error when tool recording fails', async () => {
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'testTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-tool-error',
},
};
mockCoreExecuteToolCall.mockResolvedValue({
status: 'success',
request: toolCallEvent.value,
tool: {} as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
response: {
responseParts: [],
callId: 'tool-1',
error: undefined,
errorType: undefined,
contentLength: undefined,
},
});
const events: ServerGeminiStreamEvent[] = [
toolCallEvent,
{ type: GeminiEventType.Content, value: 'Done' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
},
];
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(events))
.mockReturnValueOnce(
createStreamFromEvents([
{ type: GeminiEventType.Content, value: 'Done' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
},
]),
);
// Mock getChat to throw when recording tool calls
const mockChat = {
recordCompletedToolCalls: vi.fn().mockImplementation(() => {
throw new Error('Recording failed');
}),
};
// @ts-expect-error - Mocking internal structure
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
// @ts-expect-error - Mocking internal structure
mockGeminiClient.getCurrentSequenceModel = vi
.fn()
.mockReturnValue('model-1');
// Mock debugLogger.error
const { debugLogger } = await import('@google/gemini-cli-core');
const debugLoggerErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'Tool recording error test',
prompt_id: 'prompt-id-tool-error',
});
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Error recording completed tool call information: Error: Recording failed',
),
);
expect(getWrittenOutput()).toContain('Done');
});
});
@@ -75,6 +75,7 @@ vi.mock('../ui/commands/modelCommand.js', () => ({
}));
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
vi.mock('../ui/commands/resumeCommand.js', () => ({ resumeCommand: {} }));
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
@@ -95,7 +96,6 @@ describe('BuiltinCommandLoader', () => {
vi.clearAllMocks();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
getUseModelRouter: () => false,
getEnableMessageBusIntegration: () => false,
getEnableExtensionReloading: () => false,
} as unknown as Config;
@@ -168,28 +168,6 @@ describe('BuiltinCommandLoader', () => {
expect(permissionsCmd).toBeUndefined();
});
it('should include modelCommand when getUseModelRouter is true', async () => {
const mockConfigWithModelRouter = {
...mockConfig,
getUseModelRouter: () => true,
} as unknown as Config;
const loader = new BuiltinCommandLoader(mockConfigWithModelRouter);
const commands = await loader.loadCommands(new AbortController().signal);
const modelCmd = commands.find((c) => c.name === 'model');
expect(modelCmd).toBeDefined();
});
it('should not include modelCommand when getUseModelRouter is false', async () => {
const mockConfigWithoutModelRouter = {
...mockConfig,
getUseModelRouter: () => false,
} as unknown as Config;
const loader = new BuiltinCommandLoader(mockConfigWithoutModelRouter);
const commands = await loader.loadCommands(new AbortController().signal);
const modelCmd = commands.find((c) => c.name === 'model');
expect(modelCmd).toBeUndefined();
});
it('should include policies command when message bus integration is enabled', async () => {
const mockConfigWithMessageBus = {
...mockConfig,
@@ -220,7 +198,6 @@ describe('BuiltinCommandLoader profile', () => {
vi.resetModules();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(false),
getUseModelRouter: () => false,
getCheckpointingEnabled: () => false,
getEnableMessageBusIntegration: () => false,
getEnableExtensionReloading: () => false,
@@ -32,6 +32,7 @@ import { policiesCommand } from '../ui/commands/policiesCommand.js';
import { profileCommand } from '../ui/commands/profileCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
@@ -73,7 +74,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
initCommand,
mcpCommand,
memoryCommand,
...(this.config?.getUseModelRouter() ? [modelCommand] : []),
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
privacyCommand,
...(this.config?.getEnableMessageBusIntegration()
@@ -82,6 +83,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
...(isDevelopment ? [profileCommand] : []),
quitCommand,
restoreCommand(this.config),
resumeCommand,
statsCommand,
themeCommand,
toolsCommand,
@@ -88,7 +88,7 @@ export const createMockCommandContext = (
const targetValue = output[key];
if (
// We only want to recursivlty merge plain objects
// We only want to recursively merge plain objects
Object.prototype.toString.call(sourceValue) === '[object Object]' &&
Object.prototype.toString.call(targetValue) === '[object Object]'
) {
+5 -1
View File
@@ -148,6 +148,10 @@ const mockUIActions: UIActions = {
closeSettingsDialog: vi.fn(),
closeModelDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openSessionBrowser: vi.fn(),
closeSessionBrowser: vi.fn(),
handleResumeSession: vi.fn(),
handleDeleteSession: vi.fn(),
closePermissionsDialog: vi.fn(),
setShellModeActive: vi.fn(),
vimHandleInput: vi.fn(),
@@ -176,7 +180,7 @@ export const renderWithProviders = (
width,
mouseEventsEnabled = false,
config = configProxy as unknown as Config,
useAlternateBuffer,
useAlternateBuffer = true,
uiActions,
}: {
shellFocus?: boolean;
+44 -21
View File
@@ -164,29 +164,23 @@ describe('App', () => {
expect(lastFrame()).toContain('DialogManager');
});
it('should show Ctrl+C exit prompt when dialogs are visible and ctrlCPressedOnce is true', () => {
const ctrlCUIState = {
...mockUIState,
dialogsVisible: true,
ctrlCPressedOnce: true,
} as UIState;
it.each([
{ key: 'C', stateKey: 'ctrlCPressedOnce' },
{ key: 'D', stateKey: 'ctrlDPressedOnce' },
])(
'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true',
({ key, stateKey }) => {
const uiState = {
...mockUIState,
dialogsVisible: true,
[stateKey]: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, ctrlCUIState);
const { lastFrame } = renderWithProviders(<App />, uiState);
expect(lastFrame()).toContain('Press Ctrl+C again to exit.');
});
it('should show Ctrl+D exit prompt when dialogs are visible and ctrlDPressedOnce is true', () => {
const ctrlDUIState = {
...mockUIState,
dialogsVisible: true,
ctrlDPressedOnce: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, ctrlDUIState);
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
});
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
},
);
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
@@ -205,4 +199,33 @@ describe('App', () => {
expect(lastFrame()).toContain('MainContent\nNotifications\nComposer');
});
describe('Snapshots', () => {
it('renders default layout correctly', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame } = renderWithProviders(
<App />,
mockUIState as UIState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders screen reader layout correctly', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame } = renderWithProviders(
<App />,
mockUIState as UIState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders with dialogs visible', () => {
const dialogUIState = {
...mockUIState,
dialogsVisible: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, dialogUIState);
expect(lastFrame()).toMatchSnapshot();
});
});
});
+23 -20
View File
@@ -54,6 +54,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
...actual,
coreEvents: mockCoreEvents,
IdeClient: mockIdeClient,
writeToStdout: vi.fn((...args) =>
process.stdout.write(
...(args as Parameters<typeof process.stdout.write>),
),
),
writeToStderr: vi.fn((...args) =>
process.stderr.write(
...(args as Parameters<typeof process.stderr.write>),
),
),
patchStdio: vi.fn(() => () => {}),
createInkStdio: vi.fn(() => ({
stdout: process.stdout,
stderr: process.stderr,
})),
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
};
});
import type { LoadedSettings } from '../config/settings.js';
@@ -122,23 +139,6 @@ vi.mock('../utils/events.js');
vi.mock('../utils/handleAutoUpdate.js');
vi.mock('./utils/ConsolePatcher.js');
vi.mock('../utils/cleanup.js');
vi.mock('./utils/mouse.js', () => ({
enableMouseEvents: vi.fn(),
disableMouseEvents: vi.fn(),
}));
vi.mock('../utils/stdio.js', () => ({
writeToStdout: vi.fn((...args) =>
process.stdout.write(...(args as Parameters<typeof process.stdout.write>)),
),
writeToStderr: vi.fn((...args) =>
process.stderr.write(...(args as Parameters<typeof process.stderr.write>)),
),
patchStdio: vi.fn(() => () => {}),
createInkStdio: vi.fn(() => ({
stdout: process.stdout,
stderr: process.stderr,
})),
}));
import { useHistory } from './hooks/useHistoryManager.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
@@ -163,10 +163,13 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import {
ShellExecutionService,
writeToStdout,
enableMouseEvents,
disableMouseEvents,
} from '@google/gemini-cli-core';
import { type ExtensionManager } from '../config/extension-manager.js';
import { enableMouseEvents, disableMouseEvents } from './utils/mouse.js';
import { writeToStdout } from '../utils/stdio.js';
describe('AppContainer State Management', () => {
let mockConfig: Config;
+52 -10
View File
@@ -52,6 +52,12 @@ import {
refreshServerHierarchicalMemory,
type ModelChangedPayload,
type MemoryChangedPayload,
writeToStdout,
disableMouseEvents,
enterAlternateScreen,
enableMouseEvents,
disableLineWrapping,
shouldEnterAlternateScreen,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -92,6 +98,8 @@ import { appEvents, AppEvent } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js';
import type { SessionInfo } from '../utils/sessionUtils.js';
import { useMessageQueue } from './hooks/useMessageQueue.js';
import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useSessionStats } from './contexts/SessionContext.js';
@@ -101,16 +109,16 @@ import {
useExtensionUpdates,
} from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useSessionResume } from './hooks/useSessionResume.js';
import { type ExtensionManager } from '../config/extension-manager.js';
import { requestConsentInteractive } from '../config/extensions/consent.js';
import { useSessionBrowser } from './hooks/useSessionBrowser.js';
import { useSessionResume } from './hooks/useSessionResume.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { disableMouseEvents, enableMouseEvents } from './utils/mouse.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js';
import { writeToStdout } from '../utils/stdio.js';
import { enableBracketedPaste } from './utils/bracketedPaste.js';
const WARNING_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
@@ -372,16 +380,20 @@ export const AppContainer = (props: AppContainerProps) => {
setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
const handleEditorClose = useCallback(() => {
if (isAlternateBuffer) {
if (
shouldEnterAlternateScreen(isAlternateBuffer, config.getScreenReader())
) {
// The editor may have exited alternate buffer mode so we need to
// enter it again to be safe.
writeToStdout(ansiEscapes.enterAlternativeScreen);
enterAlternateScreen();
enableMouseEvents();
disableLineWrapping();
app.rerender();
}
enableBracketedPaste();
enableSupportedProtocol();
refreshStatic();
}, [refreshStatic, isAlternateBuffer, app]);
}, [refreshStatic, isAlternateBuffer, app, config]);
useEffect(() => {
coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose);
@@ -426,7 +438,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Session browser and resume functionality
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
useSessionResume({
const { loadHistoryForResume } = useSessionResume({
config,
historyManager,
refreshStatic,
@@ -435,6 +447,20 @@ export const AppContainer = (props: AppContainerProps) => {
resumedSessionData,
isAuthenticating,
});
const {
isSessionBrowserOpen,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
handleDeleteSession: handleDeleteSessionSync,
} = useSessionBrowser(config, loadHistoryForResume);
// Wrap handleDeleteSession to return a Promise for UIActions interface
const handleDeleteSession = useCallback(
async (session: SessionInfo): Promise<void> => {
handleDeleteSessionSync(session);
},
[handleDeleteSessionSync],
);
// Create handleAuthSelect wrapper for backward compatibility
const handleAuthSelect = useCallback(
@@ -458,12 +484,12 @@ export const AppContainer = (props: AppContainerProps) => {
config.isBrowserLaunchSuppressed()
) {
await runExitCleanup();
debugLogger.log(`
writeToStdout(`
----------------------------------------------------------------
Logging in with Google... Please restart Gemini CLI to continue.
Logging in with Google... Restarting Gemini CLI to continue.
----------------------------------------------------------------
`);
process.exit(0);
process.exit(RELAUNCH_EXIT_CODE);
}
}
setAuthState(AuthState.Authenticated);
@@ -560,6 +586,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
openEditorDialog,
openPrivacyNotice: () => setShowPrivacyNotice(true),
openSettingsDialog,
openSessionBrowser,
openModelDialog,
openPermissionsDialog,
quit: (messages: HistoryItem[]) => {
@@ -580,6 +607,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
openThemeDialog,
openEditorDialog,
openSettingsDialog,
openSessionBrowser,
openModelDialog,
setQuittingMessages,
setDebugMessage,
@@ -705,6 +733,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
handleApprovalModeChange,
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
} = useGeminiStream(
config.getGeminiClient(),
historyManager.history,
@@ -1104,6 +1133,8 @@ Logging in with Google... Please restart Gemini CLI to continue.
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
!!activePtyId && !embeddedShellFocused,
lastOutputTime,
);
const handleGlobalKeypress = useCallback(
@@ -1317,6 +1348,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
showPrivacyNotice ||
showIdeRestartPrompt ||
!!proQuotaRequest ||
isSessionBrowserOpen ||
isAuthDialogOpen ||
authState === AuthState.AwaitingApiKeyInput;
@@ -1389,6 +1421,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
debugMessage,
quittingMessages,
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
permissionsDialogProps,
@@ -1479,6 +1512,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
debugMessage,
quittingMessages,
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isPermissionsDialogOpen,
permissionsDialogProps,
@@ -1588,6 +1622,10 @@ Logging in with Google... Please restart Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
popAllMessages,
handleApiKeySubmit,
@@ -1619,6 +1657,10 @@ Logging in with Google... Please restart Gemini CLI to continue.
handleFinalSubmit,
handleClearScreen,
handleProQuotaChoice,
openSessionBrowser,
closeSessionBrowser,
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
popAllMessages,
handleApiKeySubmit,
@@ -0,0 +1,204 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from 'ink-testing-library';
import { act } from 'react';
import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe('IdeIntegrationNudge', () => {
const defaultProps = {
ide: {
name: 'vscode',
displayName: 'VS Code',
},
onComplete: vi.fn(),
};
const originalError = console.error;
afterEach(() => {
console.error = originalError;
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
beforeEach(() => {
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
/was not wrapped in act/.test(args[0])
) {
return;
}
originalError.call(console, ...args);
};
vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '');
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '');
});
it('renders correctly with default options', async () => {
const { lastFrame } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
const frame = lastFrame();
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
expect(frame).toContain('Yes');
expect(frame).toContain('No (esc)');
expect(frame).toContain("No, don't ask again");
});
it('handles "Yes" selection', async () => {
const onComplete = vi.fn();
const { stdin } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
// "Yes" is the first option and selected by default usually.
await act(async () => {
stdin.write('\r');
await delay(100);
});
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'yes',
isExtensionPreInstalled: false,
});
});
it('handles "No" selection', async () => {
const onComplete = vi.fn();
const { stdin } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
// Navigate down to "No (esc)"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
await delay(100);
});
await act(async () => {
stdin.write('\r'); // Enter
await delay(100);
});
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'no',
isExtensionPreInstalled: false,
});
});
it('handles "Dismiss" selection', async () => {
const onComplete = vi.fn();
const { stdin } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
// Navigate down to "No, don't ask again"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
await delay(100);
});
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
await delay(100);
});
await act(async () => {
stdin.write('\r'); // Enter
await delay(100);
});
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'dismiss',
isExtensionPreInstalled: false,
});
});
it('handles Escape key press', async () => {
const onComplete = vi.fn();
const { stdin } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
// Press Escape
await act(async () => {
stdin.write('\u001B');
await delay(100);
});
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'no',
isExtensionPreInstalled: false,
});
});
it('displays correct text and handles selection when extension is pre-installed', async () => {
vi.stubEnv('GEMINI_CLI_IDE_SERVER_PORT', '1234');
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp');
const onComplete = vi.fn();
const { lastFrame, stdin } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
);
await act(async () => {
await delay(100);
});
const frame = lastFrame();
expect(frame).toContain(
'If you select Yes, the CLI will have access to your open files',
);
expect(frame).not.toContain("we'll install an extension");
// Select "Yes"
await act(async () => {
stdin.write('\r');
await delay(100);
});
expect(onComplete).toHaveBeenCalledWith({
userSelection: 'yes',
isExtensionPreInstalled: true,
});
});
});
@@ -0,0 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`App > Snapshots > renders default layout correctly 1`] = `
"MainContent
Notifications
Composer"
`;
exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
"Notifications
Footer
MainContent
Composer"
`;
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"Notifications
Footer
MainContent
DialogManager"
`;
+24 -29
View File
@@ -78,38 +78,33 @@ describe('ApiAuthDialog', () => {
);
});
it('calls onSubmit when the text input is submitted', () => {
mockBuffer.text = 'submitted-key';
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
name: 'return',
it.each([
{
keyName: 'return',
sequence: '\r',
ctrl: false,
meta: false,
shift: false,
paste: false,
});
expectedCall: onSubmit,
args: ['submitted-key'],
},
{ keyName: 'escape', sequence: '\u001b', expectedCall: onCancel, args: [] },
])(
'calls $expectedCall.name when $keyName is pressed',
({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
expect(onSubmit).toHaveBeenCalledWith('submitted-key');
});
keypressHandler({
name: keyName,
sequence,
ctrl: false,
meta: false,
shift: false,
paste: false,
});
it('calls onCancel when the text input is cancelled', () => {
render(<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
name: 'escape',
sequence: '\u001b',
ctrl: false,
meta: false,
shift: false,
paste: false,
});
expect(onCancel).toHaveBeenCalled();
});
expect(expectedCall).toHaveBeenCalledWith(...args);
},
);
it('displays an error message', () => {
const { lastFrame } = render(
+136 -88
View File
@@ -25,6 +25,7 @@ import { validateAuthMethodWithSettings } from './useAuth.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { clearCachedCredentialFile } from '@google/gemini-cli-core';
import { Text } from 'ink';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
// Mocks
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -103,48 +104,52 @@ describe('AuthDialog', () => {
process.env = originalEnv;
});
it('shows Cloud Shell option when in Cloud Shell environment', () => {
process.env['CLOUD_SHELL'] = 'true';
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toContainEqual({
label: 'Use Cloud Shell user credentials',
describe('Environment Variable Effects on Auth Options', () => {
const cloudShellLabel = 'Use Cloud Shell user credentials';
const metadataServerLabel =
'Use metadata server application default credentials';
const computeAdcItem = (label: string) => ({
label,
value: AuthType.COMPUTE_ADC,
key: AuthType.COMPUTE_ADC,
});
});
it('does not show metadata server application default credentials option in Cloud Shell environment', () => {
process.env['CLOUD_SHELL'] = 'true';
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).not.toContainEqual({
label: 'Use metadata server application default credentials',
value: AuthType.COMPUTE_ADC,
key: AuthType.COMPUTE_ADC,
});
});
it('shows metadata server application default credentials option when GEMINI_CLI_USE_COMPUTE_ADC env var is true', () => {
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] = 'true';
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toContainEqual({
label: 'Use metadata server application default credentials',
value: AuthType.COMPUTE_ADC,
key: AuthType.COMPUTE_ADC,
});
});
it('does not show Cloud Shell option when when GEMINI_CLI_USE_COMPUTE_ADC env var is true', () => {
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] = 'true';
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).not.toContainEqual({
label: 'Use Cloud Shell user credentials',
value: AuthType.COMPUTE_ADC,
key: AuthType.COMPUTE_ADC,
});
it.each([
{
env: { CLOUD_SHELL: 'true' },
shouldContain: [computeAdcItem(cloudShellLabel)],
shouldNotContain: [computeAdcItem(metadataServerLabel)],
desc: 'in Cloud Shell',
},
{
env: { GEMINI_CLI_USE_COMPUTE_ADC: 'true' },
shouldContain: [computeAdcItem(metadataServerLabel)],
shouldNotContain: [computeAdcItem(cloudShellLabel)],
desc: 'with GEMINI_CLI_USE_COMPUTE_ADC',
},
{
env: {},
shouldContain: [],
shouldNotContain: [
computeAdcItem(cloudShellLabel),
computeAdcItem(metadataServerLabel),
],
desc: 'by default',
},
])(
'correctly shows/hides COMPUTE_ADC options $desc',
({ env, shouldContain, shouldNotContain }) => {
process.env = { ...env };
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
for (const item of shouldContain) {
expect(items).toContainEqual(item);
}
for (const item of shouldNotContain) {
expect(items).not.toContainEqual(item);
}
},
);
});
it('filters auth types when enforcedType is set', () => {
@@ -162,31 +167,41 @@ describe('AuthDialog', () => {
expect(initialIndex).toBe(0);
});
it('selects initial auth type from settings', () => {
props.settings.merged.security!.auth!.selectedType = AuthType.USE_VERTEX_AI;
renderWithProviders(<AuthDialog {...props} />);
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(AuthType.USE_VERTEX_AI);
});
it('selects initial auth type from GEMINI_DEFAULT_AUTH_TYPE env var', () => {
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI);
});
it('selects initial auth type from GEMINI_API_KEY env var', () => {
process.env['GEMINI_API_KEY'] = 'test-key';
renderWithProviders(<AuthDialog {...props} />);
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(AuthType.USE_GEMINI);
});
it('defaults to Login with Google', () => {
renderWithProviders(<AuthDialog {...props} />);
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(AuthType.LOGIN_WITH_GOOGLE);
describe('Initial Auth Type Selection', () => {
it.each([
{
setup: () => {
props.settings.merged.security!.auth!.selectedType =
AuthType.USE_VERTEX_AI;
},
expected: AuthType.USE_VERTEX_AI,
desc: 'from settings',
},
{
setup: () => {
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
},
expected: AuthType.USE_GEMINI,
desc: 'from GEMINI_DEFAULT_AUTH_TYPE env var',
},
{
setup: () => {
process.env['GEMINI_API_KEY'] = 'test-key';
},
expected: AuthType.USE_GEMINI,
desc: 'from GEMINI_API_KEY env var',
},
{
setup: () => {},
expected: AuthType.LOGIN_WITH_GOOGLE,
desc: 'defaults to Login with Google',
},
])('selects initial auth type $desc', ({ setup, expected }) => {
setup();
renderWithProviders(<AuthDialog {...props} />);
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(expected);
});
});
describe('handleAuthSelect', () => {
@@ -229,6 +244,7 @@ describe('AuthDialog', () => {
});
it('exits process for Login with Google when browser is suppressed', async () => {
vi.useFakeTimers();
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
@@ -241,14 +257,14 @@ describe('AuthDialog', () => {
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
await vi.runAllTimersAsync();
expect(mockedRunExitCleanup).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('Please restart Gemini CLI'),
);
expect(exitSpy).toHaveBeenCalledWith(0);
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
exitSpy.mockRestore();
logSpy.mockRestore();
vi.useRealTimers();
});
});
@@ -259,34 +275,66 @@ describe('AuthDialog', () => {
});
describe('useKeypress', () => {
it('does nothing on escape if authError is present', () => {
props.authError = 'Some error';
it.each([
{
desc: 'does nothing on escape if authError is present',
setup: () => {
props.authError = 'Some error';
},
expectations: (p: typeof props) => {
expect(p.onAuthError).not.toHaveBeenCalled();
expect(p.setAuthState).not.toHaveBeenCalled();
},
},
{
desc: 'calls onAuthError on escape if no auth method is set',
setup: () => {
props.settings.merged.security!.auth!.selectedType = undefined;
},
expectations: (p: typeof props) => {
expect(p.onAuthError).toHaveBeenCalledWith(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
},
},
{
desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set',
setup: () => {
props.settings.merged.security!.auth!.selectedType =
AuthType.USE_GEMINI;
},
expectations: (p: typeof props) => {
expect(p.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
expect(p.settings.setValue).not.toHaveBeenCalled();
},
},
])('$desc', ({ setup, expectations }) => {
setup();
renderWithProviders(<AuthDialog {...props} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expect(props.onAuthError).not.toHaveBeenCalled();
expect(props.setAuthState).not.toHaveBeenCalled();
expectations(props);
});
});
describe('Snapshots', () => {
it('renders correctly with default props', () => {
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
expect(lastFrame()).toMatchSnapshot();
});
it('calls onAuthError on escape if no auth method is set', () => {
props.settings.merged.security!.auth!.selectedType = undefined;
renderWithProviders(<AuthDialog {...props} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expect(props.onAuthError).toHaveBeenCalledWith(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
it('renders correctly with auth error', () => {
props.authError = 'Something went wrong';
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
expect(lastFrame()).toMatchSnapshot();
});
it('calls onSelect(undefined) on escape if auth method is set', () => {
props.settings.merged.security!.auth!.selectedType = AuthType.USE_GEMINI;
renderWithProviders(<AuthDialog {...props} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
);
expect(props.settings.setValue).not.toHaveBeenCalled();
it('renders correctly with enforced auth type', () => {
props.settings.merged.security!.auth!.enforcedType = AuthType.USE_GEMINI;
const { lastFrame } = renderWithProviders(<AuthDialog {...props} />);
expect(lastFrame()).toMatchSnapshot();
});
});
});
+30 -12
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
@@ -17,13 +17,13 @@ import { SettingScope } from '../../config/settings.js';
import {
AuthType,
clearCachedCredentialFile,
debugLogger,
type Config,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { AuthState } from '../types.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import { validateAuthMethodWithSettings } from './useAuth.js';
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
interface AuthDialogProps {
config: Config;
@@ -40,6 +40,7 @@ export function AuthDialog({
authError,
onAuthError,
}: AuthDialogProps): React.JSX.Element {
const [exiting, setExiting] = useState(false);
let items = [
{
label: 'Login with Google',
@@ -111,6 +112,9 @@ export function AuthDialog({
const onSelect = useCallback(
async (authType: AuthType | undefined, scope: LoadableSettingScope) => {
if (exiting) {
return;
}
if (authType) {
await clearCachedCredentialFile();
@@ -119,15 +123,12 @@ export function AuthDialog({
authType === AuthType.LOGIN_WITH_GOOGLE &&
config.isBrowserLaunchSuppressed()
) {
runExitCleanup();
debugLogger.log(
`
----------------------------------------------------------------
Logging in with Google... Please restart Gemini CLI to continue.
----------------------------------------------------------------
`,
);
process.exit(0);
setExiting(true);
setTimeout(async () => {
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}, 100);
return;
}
}
if (authType === AuthType.USE_GEMINI) {
@@ -136,7 +137,7 @@ Logging in with Google... Please restart Gemini CLI to continue.
}
setAuthState(AuthState.Unauthenticated);
},
[settings, config, setAuthState],
[settings, config, setAuthState, exiting],
);
const handleAuthSelect = (authMethod: AuthType) => {
@@ -169,6 +170,23 @@ Logging in with Google... Please restart Gemini CLI to continue.
{ isActive: true },
);
if (exiting) {
return (
<Box
borderStyle="round"
borderColor={theme.border.focused}
flexDirection="row"
padding={1}
width="100%"
alignItems="flex-start"
>
<Text color={theme.text.primary}>
Logging in with Google... Restarting Gemini CLI to continue.
</Text>
</Box>
);
}
return (
<Box
borderStyle="round"
@@ -0,0 +1,90 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render } from 'ink-testing-library';
import { act } from 'react';
import { AuthInProgress } from './AuthInProgress.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
// Mock dependencies
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('../components/CliSpinner.js', () => ({
CliSpinner: () => '[Spinner]',
}));
describe('AuthInProgress', () => {
const onTimeout = vi.fn();
const originalError = console.error;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('was not wrapped in act')
) {
return;
}
originalError.call(console, ...args);
};
});
afterEach(() => {
console.error = originalError;
vi.useRealTimers();
});
it('renders initial state with spinner', () => {
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
expect(lastFrame()).toContain('[Spinner] Waiting for auth...');
expect(lastFrame()).toContain('Press ESC or CTRL+C to cancel');
});
it('calls onTimeout when ESC is pressed', () => {
render(<AuthInProgress onTimeout={onTimeout} />);
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
keypressHandler({ name: 'escape' } as unknown as Key);
expect(onTimeout).toHaveBeenCalled();
});
it('calls onTimeout when Ctrl+C is pressed', () => {
render(<AuthInProgress onTimeout={onTimeout} />);
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
keypressHandler({ name: 'c', ctrl: true } as unknown as Key);
expect(onTimeout).toHaveBeenCalled();
});
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
const { lastFrame } = render(<AuthInProgress onTimeout={onTimeout} />);
await act(async () => {
vi.advanceTimersByTime(180000);
});
expect(onTimeout).toHaveBeenCalled();
await vi.waitUntil(
() => lastFrame()?.includes('Authentication timed out'),
{ timeout: 1000 },
);
});
it('clears timer on unmount', () => {
const { unmount } = render(<AuthInProgress onTimeout={onTimeout} />);
act(() => {
unmount();
});
vi.advanceTimersByTime(180000);
expect(onTimeout).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,57 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
│ │
│ Something went wrong │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Login with Google(not selected) Use Gemini API Key(not selected) Vertex AI │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ ? Get started │
│ │
│ How would you like to authenticate for this project? │
│ │
│ (selected) Use Gemini API Key │
│ │
│ (Use Enter to select) │
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
+271
View File
@@ -0,0 +1,271 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js';
import { AuthType, type Config } 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();
const mockValidateAuthMethod = vi.fn();
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
loadApiKey: () => mockLoadApiKey(),
};
});
vi.mock('../../config/auth.js', () => ({
validateAuthMethod: (authType: AuthType) => mockValidateAuthMethod(authType),
}));
describe('useAuth', () => {
beforeEach(() => {
vi.resetAllMocks();
process.env['GEMINI_API_KEY'] = '';
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = '';
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('validateAuthMethodWithSettings', () => {
it('should return error if auth type is enforced and does not match', () => {
const settings = {
merged: {
security: {
auth: {
enforcedType: AuthType.LOGIN_WITH_GOOGLE,
},
},
},
} as LoadedSettings;
const error = validateAuthMethodWithSettings(
AuthType.USE_GEMINI,
settings,
);
expect(error).toContain('Authentication is enforced to be oauth');
});
it('should return null if useExternal is true', () => {
const settings = {
merged: {
security: {
auth: {
useExternal: true,
},
},
},
} as LoadedSettings;
const error = validateAuthMethodWithSettings(
AuthType.LOGIN_WITH_GOOGLE,
settings,
);
expect(error).toBeNull();
});
it('should return null if authType is USE_GEMINI', () => {
const settings = {
merged: {
security: {
auth: {},
},
},
} as LoadedSettings;
const error = validateAuthMethodWithSettings(
AuthType.USE_GEMINI,
settings,
);
expect(error).toBeNull();
});
it('should call validateAuthMethod for other auth types', () => {
const settings = {
merged: {
security: {
auth: {},
},
},
} as LoadedSettings;
mockValidateAuthMethod.mockReturnValue('Validation Error');
const error = validateAuthMethodWithSettings(
AuthType.LOGIN_WITH_GOOGLE,
settings,
);
expect(error).toBe('Validation Error');
expect(mockValidateAuthMethod).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
});
describe('useAuthCommand', () => {
const mockConfig = {
refreshAuth: vi.fn(),
} as unknown as Config;
const createSettings = (selectedType?: AuthType) =>
({
merged: {
security: {
auth: {
selectedType,
},
},
},
}) as LoadedSettings;
it('should initialize with Unauthenticated state', () => {
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
expect(result.current.authState).toBe(AuthState.Unauthenticated);
});
it('should set error if no auth type is selected and no env key', async () => {
const { result } = renderHook(() =>
useAuthCommand(createSettings(undefined), mockConfig),
);
await waitFor(() => {
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(() =>
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);
});
});
it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => {
mockLoadApiKey.mockResolvedValue(null);
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
);
await waitFor(() => {
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(() =>
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');
});
});
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(() =>
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');
});
});
it('should set error if validation fails', async () => {
mockValidateAuthMethod.mockReturnValue('Validation Failed');
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
await waitFor(() => {
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(() =>
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);
});
});
it('should authenticate successfully for valid auth type', async () => {
const { result } = 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();
});
});
it('should handle refreshAuth failure', async () => {
(mockConfig.refreshAuth as Mock).mockRejectedValue(
new Error('Auth Failed'),
);
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
await waitFor(() => {
expect(result.current.authError).toContain('Failed to login');
expect(result.current.authState).toBe(AuthState.Updating);
});
});
});
});

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