mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 05:31:02 -07:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de13a2ccb4 | |||
| ee6b01f9c7 | |||
| a35d001fde | |||
| 95f9032bf4 | |||
| 578c497417 | |||
| 6169ef04ba | |||
| 933e0dc8ec | |||
| 90a5dc3da5 | |||
| 403d29c63c | |||
| e1ea24806e | |||
| d2a6cff4df | |||
| acf5ed595f | |||
| 03845198ce | |||
| d8a3d08f8e | |||
| 6f9118dca6 | |||
| d0b6701fba | |||
| 94c3eecb99 | |||
| 098e5c281c | |||
| f2c52f777c | |||
| 2b41263aa7 | |||
| 404a4468d8 | |||
| 7f67c7f952 | |||
| c8540b5744 | |||
| f6d97d4488 | |||
| 87712a0a7c | |||
| ba0e053ffc | |||
| d14779b264 | |||
| 5411f4a667 | |||
| 2034098780 | |||
| d53a5c4fb5 | |||
| f6ee025c46 | |||
| e50bf6adad | |||
| c21b6899e1 | |||
| 95693e265e | |||
| 569c6f1dd0 | |||
| 7350399a50 | |||
| c2a741ee36 | |||
| e177314a4a | |||
| dadd606c0d | |||
| b3fcddde11 | |||
| e205a468d9 | |||
| bdf80ea7c0 | |||
| 5e218a5630 | |||
| 8c36b10682 | |||
| 42c2e1b217 | |||
| 1e715d1e5c | |||
| 9f9a2fa844 | |||
| 64eb14ab11 | |||
| 0713c86dec | |||
| 99c5bf2e97 | |||
| fe67ef63c1 | |||
| 19d4384f16 | |||
| d351f07758 | |||
| d72f35c2e7 | |||
| aeffa2a460 | |||
| 030a5ace97 | |||
| b97661553f | |||
| 3370644ffe | |||
| 0f0b463a2f | |||
| 8d082a904d | |||
| 61582678bf | |||
| 613b8a4527 | |||
| 5982abeffb | |||
| 78b10dccf1 | |||
| 85bc25a765 | |||
| fec0eba07e | |||
| f92e79eba0 | |||
| 3e50be1658 | |||
| 9937fb2220 |
@@ -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}}.
|
||||
"""
|
||||
@@ -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}}.
|
||||
"""
|
||||
@@ -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)'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[](https://github.com/google-gemini/gemini-cli/actions/workflows/e2e.yml)
|
||||
[](https://www.npmjs.com/package/@google/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>
|
||||
[](https://codewiki.google/github.com/google-gemini/gemini-cli)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
"label": "Sandbox",
|
||||
"slug": "docs/cli/sandbox"
|
||||
},
|
||||
{
|
||||
"label": "Session Management",
|
||||
"slug": "docs/cli/session-management"
|
||||
},
|
||||
{
|
||||
"label": "Settings",
|
||||
"slug": "docs/cli/settings"
|
||||
|
||||
@@ -29,7 +29,6 @@ export default tseslint.config(
|
||||
// Global ignores
|
||||
ignores: [
|
||||
'node_modules/*',
|
||||
'.integration-tests/**',
|
||||
'eslint.config.js',
|
||||
'packages/**/dist/**',
|
||||
'bundle/**',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+29
-25
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]'
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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"
|
||||
`;
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -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
Reference in New Issue
Block a user