Compare commits

..

3 Commits

Author SHA1 Message Date
Michael Bleigh f01a7d2eac fix 2026-03-19 19:56:35 -07:00
Michael Bleigh 9db123b3b3 Merge branch 'main' of github.com:google-gemini/gemini-cli into mb/tilth-read 2026-03-19 19:56:01 -07:00
Michael Bleigh 7237159d9b fix(core): use tilth budget to format output and remove redundant prefix 2026-03-19 19:10:24 -07:00
420 changed files with 7336 additions and 12879 deletions
-10
View File
@@ -7,15 +7,5 @@
},
"general": {
"devtools": true
},
"agents": {
"overrides": {
"browser_agent": { "enabled": true }
},
"browser": {
"headless": true,
"sessionMode": "isolated",
"allowedDomains": ["*.com"]
}
}
}
-69
View File
@@ -1,69 +0,0 @@
name: 'Evals: PR Guidance'
on:
pull_request:
paths:
- 'packages/core/src/**/*.ts'
- '!**/*.test.ts'
- '!**/*.test.tsx'
permissions:
pull-requests: 'write'
contents: 'read'
jobs:
provide-guidance:
name: 'Model Steering Guidance'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
with:
fetch-depth: 0
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Detect Steering Changes'
id: 'detect'
run: |
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Analyze PR Content'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
id: 'analysis'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
# Check for behavioral eval changes
EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true)
if [ -z "$EVAL_CHANGES" ]; then
echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT"
fi
# Check if user is a maintainer (has write/admin access)
USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission')
if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then
echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT"
fi
- name: 'Post Guidance Comment'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
with:
comment-tag: 'eval-guidance-bot'
message: |
### 🧠 Model Steering Guidance
This PR modifies files that affect the model's behavior (prompts, tools, or instructions).
${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- ⚠️ **Consider adding Evals:** No behavioral evaluations (`evals/*.eval.ts`) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.' || '' }}
${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- 🚀 **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }}
---
*This is an automated guidance message triggered by steering logic signatures.*
-1
View File
@@ -61,7 +61,6 @@ jobs:
GEMINI_MODEL: '${{ matrix.model }}'
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
VITEST_RETRY: 0
run: |
CMD="npm run test:all_evals"
PATTERN="${TEST_NAME_PATTERN}"
-1
View File
@@ -50,7 +50,6 @@ These commands are available within the interactive REPL.
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. |
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
-107
View File
@@ -1,107 +0,0 @@
# Git Worktrees (experimental)
When working on multiple tasks at once, you can use Git worktrees to give each
Gemini session its own copy of the codebase. Git worktrees create separate
working directories that each have their own files and branch while sharing the
same repository history. This prevents changes in one session from colliding
with another.
Learn more about [session management](./session-management.md).
<!-- prettier-ignore -->
> [!NOTE]
> This is an experimental feature currently under active development. Your
> feedback is invaluable as we refine this feature. If you have ideas,
> suggestions, or encounter issues:
>
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub.
> - Use the **/bug** command within Gemini CLI to file an issue.
Learn more in the official Git worktree
[documentation](https://git-scm.com/docs/git-worktree).
## How to enable Git worktrees
Git worktrees are an experimental feature. You must enable them in your settings
using the `/settings` command or by manually editing your `settings.json` file.
1. Use the `/settings` command.
2. Search for and set **Enable Git Worktrees** to `true`.
Alternatively, add the following to your `settings.json`:
```json
{
"experimental": {
"worktrees": true
}
}
```
## How to use Git worktrees
Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini
CLI in it.
- **Start with a specific name:** The value you pass becomes both the directory
name (within `.gemini/worktrees/`) and the branch name.
```bash
gemini --worktree feature-search
```
- **Start with a random name:** If you omit the name, Gemini generates a random
one automatically (for example, `worktree-a1b2c3d4`).
```bash
gemini --worktree
```
<!-- prettier-ignore -->
> [!NOTE]
> Remember to initialize your development environment in each new
> worktree according to your project's setup. Depending on your stack, this
> might include running dependency installation (`npm install`, `yarn`), setting
> up virtual environments, or following your project's standard build process.
## How to exit a Git worktree session
When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the
worktree intact so your work is not lost. This includes your uncommitted changes
(modified files, staged changes, or untracked files) and any new commits you
have made.
Gemini prioritizes a fast and safe exit: it **does not automatically delete**
your worktree or branch. You are responsible for cleaning up your worktrees
manually once you are finished with them.
When you exit, Gemini displays instructions on how to resume your work or how to
manually remove the worktree if you no longer need it.
## Resuming work in a Git worktree
To resume a session in a worktree, navigate to the worktree directory and start
Gemini CLI with the `--resume` flag and the session ID:
```bash
cd .gemini/worktrees/feature-search
gemini --resume <session_id>
```
## Managing Git worktrees manually
For more control over worktree location and branch configuration, or to clean up
a preserved worktree, you can use Git directly:
- **Clean up a preserved Git worktree:**
```bash
git worktree remove .gemini/worktrees/feature-search --force
git branch -D worktree-feature-search
```
- **Create a Git worktree manually:**
```bash
git worktree add ../project-feature-search -b feature-search
cd ../project-feature-search && gemini
```
[Open an issue]: https://github.com/google-gemini/gemini-cli/issues
-6
View File
@@ -96,12 +96,6 @@ Compatibility aliases:
- `/chat ...` works for the same commands.
- `/resume checkpoints ...` also remains supported during migration.
## Parallel sessions with Git worktrees
When working on multiple tasks at once, you can use
[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of
the codebase. This prevents changes in one session from colliding with another.
## Managing sessions
You can list and delete sessions to keep your history organized and manage disk
-8
View File
@@ -101,13 +101,6 @@ they appear in the UI.
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
### Agents
| UI Label | Setting | Description | Default |
| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------- | ------- |
| Confirm Sensitive Actions | `agents.browser.confirmSensitiveActions` | Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script). | `false` |
| Block File Uploads | `agents.browser.blockFileUploads` | Hard-block file upload requests from the browser agent. | `false` |
### Context
| UI Label | Setting | Description | Default |
@@ -158,7 +151,6 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
-29
View File
@@ -306,7 +306,6 @@ Emitted at startup with the CLI configuration.
- `extension_ids` (string)
- `extensions_count` (int)
- `auth_type` (string)
- `worktree_active` (boolean)
- `github_workflow_name` (string, optional)
- `github_repository_hash` (string, optional)
- `github_event_name` (string, optional)
@@ -904,20 +903,6 @@ Logs keychain availability checks.
- `available` (boolean)
##### `gemini_cli.startup_stats`
Logs detailed startup performance statistics.
<details>
<summary>Attributes</summary>
- `phases` (json array of startup phases)
- `os_platform` (string)
- `os_release` (string)
- `is_docker` (boolean)
</details>
</details>
### Metrics
@@ -934,20 +919,6 @@ Gemini CLI exports several custom metrics.
Incremented once per CLI startup.
##### Onboarding
Tracks onboarding flow from authentication to the user
- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the
authentication flow begins.
- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user
onboarding flow completes successfully.
<details>
<summary>Attributes (Success)</summary>
- `user_tier` (string)
##### Tools
##### `gemini_cli.tool.call.count`
+1 -2
View File
@@ -23,7 +23,7 @@ Gemini CLI creates a copy of the extension during installation. You must run
GitHub, you must have `git` installed on your machine.
```bash
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent] [--skip-settings]
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
```
- `<source>`: The GitHub URL or local path of the extension.
@@ -31,7 +31,6 @@ gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release]
- `--auto-update`: Enable automatic updates for this extension.
- `--pre-release`: Enable installation of pre-release versions.
- `--consent`: Acknowledge security risks and skip the confirmation prompt.
- `--skip-settings`: Skip the configuration on install process.
### Uninstall an extension
+2 -2
View File
@@ -470,5 +470,5 @@ console.error('Consolidating memories for session end...');
While project-level hooks are great for specific repositories, you can share
your hooks across multiple projects by packaging them as a
[Gemini CLI extension](../extensions/index.md). This provides version control,
easy distribution, and centralized management.
[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md).
This provides version control, easy distribution, and centralized management.
-16
View File
@@ -1210,17 +1210,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Disable user input on browser window during automation.
- **Default:** `true`
- **`agents.browser.confirmSensitiveActions`** (boolean):
- **Description:** Require manual confirmation for sensitive browser actions
(e.g., fill_form, evaluate_script).
- **Default:** `false`
- **Requires restart:** Yes
- **`agents.browser.blockFileUploads`** (boolean):
- **Description:** Hard-block file upload requests from the browser agent.
- **Default:** `false`
- **Requires restart:** Yes
#### `context`
- **`context.fileName`** (string | string[]):
@@ -1538,11 +1527,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.worktrees`** (boolean):
- **Description:** Enable automated Git worktree management for parallel work.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionManagement`** (boolean):
- **Description:** Enable extension management features.
- **Default:** `true`
+9 -14
View File
@@ -262,8 +262,8 @@ Here is a breakdown of the fields available in a TOML policy rule:
# A unique name for the tool, or an array of names.
toolName = "run_shell_command"
# (Optional) The name of a subagent. If provided, the rule only applies to tool
# calls made by this specific subagent.
# (Optional) The name of a subagent. If provided, the rule only applies to tool calls
# made by this specific subagent.
subagent = "generalist"
# (Optional) The name of an MCP server. Can be combined with toolName
@@ -278,17 +278,14 @@ toolAnnotations = { readOnlyHint = true }
argsPattern = '"command":"(git|npm)'
# (Optional) A string or array of strings that a shell command must start with.
# This is syntactic sugar for `toolName = "run_shell_command"` and an
# `argsPattern`.
# This is syntactic sugar for `toolName = "run_shell_command"` and an `argsPattern`.
commandPrefix = "git"
# (Optional) A regex to match against the entire shell command.
# This is also syntactic sugar for `toolName = "run_shell_command"`.
# Note: This pattern is tested against the JSON representation of the arguments
# (e.g., `{"command":"<your_command>"}`). Because it prepends `"command":"`,
# it effectively matches from the start of the command.
# Anchors like `^` or `$` apply to the full JSON string,
# so `^` should usually be avoided here.
# Note: This pattern is tested against the JSON representation of the arguments (e.g., `{"command":"<your_command>"}`).
# Because it prepends `"command":"`, it effectively matches from the start of the command.
# Anchors like `^` or `$` apply to the full JSON string, so `^` should usually be avoided here.
# You cannot use commandPrefix and commandRegex in the same rule.
commandRegex = "git (commit|push)"
@@ -298,16 +295,14 @@ decision = "ask_user"
# The priority of the rule, from 0 to 999.
priority = 10
# (Optional) A custom message to display when a tool call is denied by this
# rule. This message is returned to the model and user,
# useful for explaining *why* it was denied.
# (Optional) A custom message to display when a tool call is denied by this rule.
# This message is returned to the model and user, useful for explaining *why* it was denied.
deny_message = "Deletion is permanent"
# (Optional) An array of approval modes where this rule is active.
modes = ["autoEdit"]
# (Optional) A boolean to restrict the rule to interactive (true) or
# non-interactive (false) environments.
# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments.
# If omitted, the rule applies to both.
interactive = true
```
-5
View File
@@ -99,11 +99,6 @@
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{
"label": "Git worktrees",
"badge": "🔬",
"slug": "docs/cli/git-worktrees"
},
{
"label": "Hooks",
"collapsed": true,
+12 -10
View File
@@ -35,19 +35,13 @@ const commonRestrictedSyntaxRules = [
message:
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
},
{
selector:
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
message:
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
},
];
export default tseslint.config(
{
// Global ignores
ignores: [
'**/node_modules/**',
'node_modules/*',
'eslint.config.js',
'packages/**/dist/**',
'bundle/**',
@@ -56,7 +50,7 @@ export default tseslint.config(
'dist/**',
'evals/**',
'packages/test-utils/**',
'.gemini/**',
'.gemini/skills/**',
'**/*.d.ts',
],
},
@@ -139,7 +133,16 @@ export default tseslint.config(
'no-cond-assign': 'error',
'no-debugger': 'error',
'no-duplicate-case': 'error',
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
'no-restricted-syntax': [
'error',
...commonRestrictedSyntaxRules,
{
selector:
'UnaryExpression[operator="typeof"] > MemberExpression[computed=true][property.type="Literal"]',
message:
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
},
],
'no-unsafe-finally': 'error',
'no-unused-expressions': 'off', // Disable base rule
'@typescript-eslint/no-unused-expressions': [
@@ -158,7 +161,6 @@ export default tseslint.config(
'@typescript-eslint/await-thenable': ['error'],
'@typescript-eslint/no-floating-promises': ['error'],
'@typescript-eslint/no-unnecessary-type-assertion': ['error'],
'@typescript-eslint/no-misused-spread': ['error'],
'no-restricted-imports': [
'error',
{
+1 -18
View File
@@ -15,26 +15,9 @@ import fs from 'node:fs';
import path from 'node:path';
import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core';
/**
* Config overrides for evals, with tool-restriction fields explicitly
* forbidden. Evals must test against the full, default tool set to ensure
* realistic behavior.
*/
interface EvalConfigOverrides {
/** Restricting tools via excludeTools in evals is forbidden. */
excludeTools?: never;
/** Restricting tools via coreTools in evals is forbidden. */
coreTools?: never;
/** Restricting tools via allowedTools in evals is forbidden. */
allowedTools?: never;
/** Restricting tools via mainAgentTools in evals is forbidden. */
mainAgentTools?: never;
[key: string]: unknown;
}
export interface AppEvalCase {
name: string;
configOverrides?: EvalConfigOverrides;
configOverrides?: any;
prompt: string;
timeout?: number;
files?: Record<string, string>;
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('CliHelpAgent Delegation', () => {
evalTest('USUALLY_PASSES', {
name: 'should delegate to cli_help agent for subagent creation questions',
params: {
settings: {
experimental: {
enableAgents: true,
},
},
},
prompt: 'Help me create a subagent in this project',
timeout: 60000,
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs();
const toolCallIndex = toolLogs.findIndex(
(log) => log.toolRequest.name === 'cli_help',
);
expect(toolCallIndex).toBeGreaterThan(-1);
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
},
});
});
+4
View File
@@ -21,6 +21,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'file1.ts': 'console.log("no semi")',
@@ -64,6 +65,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'src/a.ts': 'export const a = 1;',
@@ -104,6 +106,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'README.md': 'This is a proyect.',
@@ -138,6 +141,7 @@ describe('generalist_delegation', () => {
experimental: {
enableAgents: true,
},
excludeTools: ['run_shell_command'],
},
files: {
'src/VERSION': '1.2.3',
+4 -2
View File
@@ -12,9 +12,10 @@ import { appEvalTest } from './app-test-helper.js';
import { PolicyDecision } from '@google/gemini-cli-core';
describe('Model Steering Behavioral Evals', () => {
appEvalTest('USUALLY_PASSES', {
appEvalTest('ALWAYS_PASSES', {
name: 'Corrective Hint: Model switches task based on hint during tool turn',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {
@@ -51,9 +52,10 @@ describe('Model Steering Behavioral Evals', () => {
},
});
appEvalTest('USUALLY_PASSES', {
appEvalTest('ALWAYS_PASSES', {
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
configOverrides: {
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
modelSteering: true,
},
files: {},
+60 -8
View File
@@ -16,7 +16,9 @@ describe('save_memory', () => {
const rememberingFavoriteColor = "Agent remembers user's favorite color";
evalTest('ALWAYS_PASSES', {
name: rememberingFavoriteColor,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `remember that my favorite color is blue.
what is my favorite color? tell me that and surround it with $ symbol`,
@@ -36,7 +38,9 @@ describe('save_memory', () => {
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
evalTest('USUALLY_PASSES', {
name: rememberingCommandRestrictions,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I don't want you to ever run npm commands.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -55,7 +59,9 @@ describe('save_memory', () => {
const rememberingWorkflow = 'Agent remembers workflow preferences';
evalTest('USUALLY_PASSES', {
name: rememberingWorkflow,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I want you to always lint after building.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -75,7 +81,9 @@ describe('save_memory', () => {
'Agent ignores temporary conversation details';
evalTest('ALWAYS_PASSES', {
name: ignoringTemporaryInformation,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I'm going to get a coffee.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -98,7 +106,9 @@ describe('save_memory', () => {
const rememberingPetName = "Agent remembers user's pet's name";
evalTest('ALWAYS_PASSES', {
name: rememberingPetName,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `Please remember that my dog's name is Buddy.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -117,7 +127,9 @@ describe('save_memory', () => {
const rememberingCommandAlias = 'Agent remembers custom command aliases';
evalTest('ALWAYS_PASSES', {
name: rememberingCommandAlias,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `When I say 'start server', you should run 'npm run dev'.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -137,6 +149,18 @@ describe('save_memory', () => {
"Agent ignores workspace's database schema location";
evalTest('USUALLY_PASSES', {
name: ignoringDbSchemaLocation,
params: {
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -156,7 +180,9 @@ describe('save_memory', () => {
"Agent remembers user's coding style preference";
evalTest('ALWAYS_PASSES', {
name: rememberingCodingStyle,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `I prefer to use tabs instead of spaces for indentation.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
@@ -176,6 +202,18 @@ describe('save_memory', () => {
'Agent ignores workspace build artifact location';
evalTest('USUALLY_PASSES', {
name: ignoringBuildArtifactLocation,
params: {
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -194,6 +232,18 @@ describe('save_memory', () => {
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
evalTest('USUALLY_PASSES', {
name: ignoringMainEntryPoint,
params: {
settings: {
tools: {
core: [
'save_memory',
'list_directory',
'read_file',
'run_shell_command',
],
},
},
},
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
@@ -212,7 +262,9 @@ describe('save_memory', () => {
const rememberingBirthday = "Agent remembers user's birthday";
evalTest('ALWAYS_PASSES', {
name: rememberingBirthday,
params: {
settings: { tools: { core: ['save_memory'] } },
},
prompt: `My birthday is on June 15th.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
+1 -17
View File
@@ -197,25 +197,9 @@ export function symlinkNodeModules(testDir: string) {
}
}
/**
* Settings that are forbidden in evals. Evals should never restrict which
* tools are available they must test against the full, default tool set
* to ensure realistic behavior.
*/
interface ForbiddenToolSettings {
tools?: {
/** Restricting core tools in evals is forbidden. */
core?: never;
[key: string]: unknown;
};
}
export interface EvalCase {
name: string;
params?: {
settings?: ForbiddenToolSettings & Record<string, unknown>;
[key: string]: unknown;
};
params?: Record<string, any>;
prompt: string;
timeout?: number;
files?: Record<string, string>;
-4
View File
@@ -16,10 +16,6 @@ export default defineConfig({
},
test: {
testTimeout: 300000, // 5 minutes
// Retry in CI but not nightly to avoid blocking on API error.
retry: process.env['VITEST_RETRY']
? parseInt(process.env['VITEST_RETRY'], 10)
: 3,
reporters: ['default', 'json'],
outputFile: {
json: 'evals/logs/report.json',
@@ -1,4 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have opened the page and the title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The task is complete. The page title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":20,"totalTokenCount":320}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":400,"candidatesTokenCount":5,"totalTokenCount":405}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
-32
View File
@@ -175,36 +175,4 @@ priority = 200
expect(output).toContain('browser_agent');
expect(output).toContain('completed successfully');
});
it('should show the visible warning when browser agent starts in existing session mode', async () => {
rig.setup('browser-session-warning', {
fakeResponsesPath: join(__dirname, 'browser-agent.cleanup.responses'),
settings: {
general: {
enableAutoUpdateNotification: false,
},
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
sessionMode: 'existing',
headless: true,
},
},
},
});
const stdout = await rig.runCommand(['Open https://example.com'], {
env: {
GEMINI_API_KEY: 'fake-key',
GEMINI_TELEMETRY_DISABLED: 'true',
DEV: 'true',
},
});
expect(stdout).toContain('saved logins will be visible');
});
});
+3 -7
View File
@@ -34,20 +34,16 @@ describe('extension install', () => {
writeFileSync(testServerPath, extension);
try {
const result = await rig.runCommand(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension-install');
const listResult = await rig.runCommand([
'--debug',
'extensions',
'list',
]);
const listResult = await rig.runCommand(['extensions', 'list']);
expect(listResult).toContain('test-extension-install');
writeFileSync(testServerPath, extensionUpdate);
const updateResult = await rig.runCommand(
['--debug', 'extensions', 'update', `test-extension-install`],
['extensions', 'update', `test-extension-install`],
{ stdin: 'y\n' },
);
expect(updateResult).toContain('0.0.2');
+1 -1
View File
@@ -66,7 +66,7 @@ describe('extension reloading', () => {
}
const result = await rig.runCommand(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
['extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
+3 -214
View File
@@ -7,10 +7,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, poll, normalizePath } from './test-helper.js';
import { join } from 'node:path';
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
import os from 'node:os';
import { writeFileSync } from 'node:fs';
describe('Hooks System Integration', { timeout: 120000 }, () => {
describe('Hooks System Integration', () => {
let rig: TestRig;
beforeEach(() => {
@@ -2017,10 +2016,6 @@ console.log(JSON.stringify({
// 3. Final setup with full settings
rig.setup('Hook Disabling Multiple Ops', {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.disabled-via-command.responses',
),
settings: {
hooksConfig: {
enabled: true,
@@ -2235,7 +2230,7 @@ console.log(JSON.stringify({
// The hook should have stopped execution message (returned from tool)
expect(result).toContain(
'Agent execution stopped by hook: Emergency Stop triggered by hook',
'Agent execution stopped: Emergency Stop triggered by hook',
);
// Tool should NOT be called successfully (it was blocked/stopped)
@@ -2247,210 +2242,4 @@ console.log(JSON.stringify({
expect(writeFileCalls).toHaveLength(0);
});
});
describe('Hooks "ask" Decision Integration', () => {
it(
'should force confirmation prompt when hook returns "ask" decision even in YOLO mode',
{ timeout: 60000 },
async () => {
const testName =
'should force confirmation prompt when hook returns "ask" decision even in YOLO mode';
// 1. Setup hook script that returns 'ask' decision
const hookOutput = {
decision: 'ask',
systemMessage: 'Confirmation forced by security hook',
hookSpecificOutput: {
hookEventName: 'BeforeTool',
},
};
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
hookOutput,
)}));`;
// Create script path predictably
const scriptPath = join(os.tmpdir(), 'gemini-cli-tests-ask-hook.js');
writeFileSync(scriptPath, hookScript);
// 2. Setup rig with YOLO mode enabled but with the 'ask' hook
rig.setup(testName, {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.allow-tool.responses',
),
settings: {
debugMode: true,
tools: {
approval: 'yolo',
},
general: {
enableAutoUpdateNotification: false,
},
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
// Bypass terminal setup prompt and other startup banners
const stateDir = join(rig.homeDir!, '.gemini');
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'state.json'),
JSON.stringify({
terminalSetupPromptShown: true,
hasSeenScreenReaderNudge: true,
tipsShown: 100,
}),
);
// 3. Run interactive and verify prompt appears despite YOLO mode
const run = await rig.runInteractive();
// Wait for prompt to appear
await run.expectText('Type your message', 30000);
// Send prompt that will trigger write_file
await run.type('Create a file called ask-test.txt with content "test"');
await run.type('\r');
// Wait for the FORCED confirmation prompt to appear
// It should contain the system message from the hook
await run.expectText('Confirmation forced by security hook', 30000);
await run.expectText('Allow', 5000);
// 4. Approve the permission
await run.type('y');
await run.type('\r');
// Wait for command to execute
await run.expectText('approved.txt', 30000);
// Should find the tool call
const foundWriteFile = await rig.waitForToolCall('write_file');
expect(foundWriteFile).toBeTruthy();
// File should be created
const fileContent = rig.readFile('approved.txt');
expect(fileContent).toBe('Approved content');
},
);
it(
'should allow cancelling when hook forces "ask" decision',
{ timeout: 60000 },
async () => {
const testName =
'should allow cancelling when hook forces "ask" decision';
const hookOutput = {
decision: 'ask',
systemMessage: 'Confirmation forced for cancellation test',
hookSpecificOutput: {
hookEventName: 'BeforeTool',
},
};
const hookScript = `console.log(JSON.stringify(${JSON.stringify(
hookOutput,
)}));`;
const scriptPath = join(
os.tmpdir(),
'gemini-cli-tests-ask-cancel-hook.js',
);
writeFileSync(scriptPath, hookScript);
rig.setup(testName, {
fakeResponsesPath: join(
import.meta.dirname,
'hooks-system.allow-tool.responses',
),
settings: {
debugMode: true,
tools: {
approval: 'yolo',
},
general: {
enableAutoUpdateNotification: false,
},
hooksConfig: {
enabled: true,
},
hooks: {
BeforeTool: [
{
matcher: 'write_file',
hooks: [
{
type: 'command',
command: `node "${scriptPath}"`,
timeout: 5000,
},
],
},
],
},
},
});
// Bypass terminal setup prompt and other startup banners
const stateDir = join(rig.homeDir!, '.gemini');
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
writeFileSync(
join(stateDir, 'state.json'),
JSON.stringify({
terminalSetupPromptShown: true,
hasSeenScreenReaderNudge: true,
tipsShown: 100,
}),
);
const run = await rig.runInteractive();
// Wait for prompt to appear
await run.expectText('Type your message', 30000);
await run.type(
'Create a file called cancel-test.txt with content "test"',
);
await run.type('\r');
await run.expectText(
'Confirmation forced for cancellation test',
30000,
);
// 4. Deny the permission using option 4
await run.type('4');
await run.type('\r');
// Wait for cancellation message
await run.expectText('Cancelled', 15000);
// Tool should NOT be called successfully
const toolLogs = rig.readToolLogs();
const writeFileCalls = toolLogs.filter(
(t) =>
t.toolRequest.name === 'write_file' &&
t.toolRequest.success === true,
);
expect(writeFileCalls).toHaveLength(0);
},
);
});
});
+1 -1
View File
@@ -51,7 +51,7 @@
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
"lint": "eslint . --cache --max-warnings 0",
"lint": "eslint . --cache",
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
"lint:ci": "npm run lint:all",
"lint:all": "node scripts/lint.js",
@@ -12,46 +12,48 @@ import {
beforeEach,
afterEach,
type MockInstance,
type Mock,
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
import * as core from '@google/gemini-cli-core';
import {
ExtensionManager,
type inferInstallMetadata,
} from '../../config/extension-manager.js';
import type {
promptForConsentNonInteractive,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import type {
isWorkspaceTrusted,
loadTrustedFolders,
} from '../../config/trustedFolders.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
import * as path from 'node:path';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
const {
mockInstallOrUpdateExtension,
mockLoadExtensions,
mockExtensionManager,
mockRequestConsentNonInteractive,
mockPromptForConsentNonInteractive,
mockStat,
mockInferInstallMetadata,
mockIsWorkspaceTrusted,
mockLoadTrustedFolders,
mockDiscover,
} = vi.hoisted(() => {
const mockLoadExtensions = vi.fn();
const mockInstallOrUpdateExtension = vi.fn();
const mockExtensionManager = vi.fn().mockImplementation(() => ({
loadExtensions: mockLoadExtensions,
installOrUpdateExtension: mockInstallOrUpdateExtension,
}));
return {
mockLoadExtensions,
mockInstallOrUpdateExtension,
mockExtensionManager,
mockRequestConsentNonInteractive: vi.fn(),
mockPromptForConsentNonInteractive: vi.fn(),
mockStat: vi.fn(),
mockInferInstallMetadata: vi.fn(),
mockIsWorkspaceTrusted: vi.fn(),
mockLoadTrustedFolders: vi.fn(),
mockDiscover: vi.fn(),
};
});
const mockInstallOrUpdateExtension: Mock<
typeof ExtensionManager.prototype.installOrUpdateExtension
> = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockPromptForConsentNonInteractive: Mock<
typeof promptForConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
() => vi.fn(),
);
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
vi.fn(),
);
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
vi.fn(),
);
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
vi.hoisted(() => vi.fn());
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
@@ -82,7 +84,6 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
...(await importOriginal<
typeof import('../../config/extension-manager.js')
>()),
ExtensionManager: mockExtensionManager,
inferInstallMetadata: mockInferInstallMetadata,
}));
@@ -116,18 +117,19 @@ describe('handleInstall', () => {
let processSpy: MockInstance;
beforeEach(() => {
debugLogSpy = vi
.spyOn(core.debugLogger, 'log')
.mockImplementation(() => {});
debugErrorSpy = vi
.spyOn(core.debugLogger, 'error')
.mockImplementation(() => {});
debugLogSpy = vi.spyOn(core.debugLogger, 'log');
debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockLoadExtensions.mockResolvedValue([]);
mockInstallOrUpdateExtension.mockReset();
vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
[],
);
vi.spyOn(
ExtensionManager.prototype,
'installOrUpdateExtension',
).mockImplementation(mockInstallOrUpdateExtension);
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
mockDiscover.mockResolvedValue({
@@ -161,7 +163,12 @@ describe('handleInstall', () => {
});
afterEach(() => {
mockInstallOrUpdateExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
mockInferInstallMetadata.mockClear();
vi.clearAllMocks();
vi.restoreAllMocks();
});
function createMockExtension(
@@ -281,39 +288,6 @@ describe('handleInstall', () => {
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should pass promptForSetting when skipSettings is not provided', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'test-extension',
} as unknown as core.GeminiCLIExtension);
await handleInstall({
source: 'http://google.com',
});
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
requestSetting: promptForSetting,
}),
);
});
it('should pass null for requestSetting when skipSettings is true', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'test-extension',
} as unknown as core.GeminiCLIExtension);
await handleInstall({
source: 'http://google.com',
skipSettings: true,
});
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
requestSetting: null,
}),
);
});
it('should proceed if local path is already trusted', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
@@ -37,7 +37,6 @@ interface InstallArgs {
autoUpdate?: boolean;
allowPreRelease?: boolean;
consent?: boolean;
skipSettings?: boolean;
}
export async function handleInstall(args: InstallArgs) {
@@ -154,7 +153,7 @@ export async function handleInstall(args: InstallArgs) {
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent,
requestSetting: args.skipSettings ? null : promptForSetting,
requestSetting: promptForSetting,
settings,
});
await extensionManager.loadExtensions();
@@ -197,11 +196,6 @@ export const installCommand: CommandModule = {
type: 'boolean',
default: false,
})
.option('skip-settings', {
describe: 'Skip the configuration on install process.',
type: 'boolean',
default: false,
})
.check((argv) => {
if (!argv.source) {
throw new Error('The source argument must be provided.');
@@ -220,8 +214,6 @@ export const installCommand: CommandModule = {
allowPreRelease: argv['pre-release'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
skipSettings: argv['skip-settings'] as boolean | undefined,
});
await exitCli();
},
-1
View File
@@ -54,7 +54,6 @@ export async function getMcpServersFromConfig(
return;
}
mcpServers[key] = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...server,
extension,
};
-72
View File
@@ -226,51 +226,6 @@ afterEach(() => {
});
describe('parseArguments', () => {
describe('worktree', () => {
it('should parse --worktree flag when provided with a name', async () => {
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = true;
const argv = await parseArguments(settings);
expect(argv.worktree).toBe('my-feature');
});
it('should generate a random name when --worktree is provided without a name', async () => {
process.argv = ['node', 'script.js', '--worktree'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = true;
const argv = await parseArguments(settings);
expect(argv.worktree).toBeDefined();
expect(argv.worktree).not.toBe('');
expect(typeof argv.worktree).toBe('string');
});
it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => {
process.argv = ['node', 'script.js', '--worktree', 'feature'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = false;
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments(settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
});
it.each([
{
description: 'long flags',
@@ -1716,7 +1671,6 @@ describe('loadCliConfig with admin.mcp.config', () => {
const serverA = config.getMcpServers()?.['serverA'];
expect(serverA).toEqual({
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...localMcpServers['serverA'],
type: 'sse',
url: 'https://admin-server-a.com/sse',
@@ -1767,7 +1721,6 @@ describe('loadCliConfig with admin.mcp.config', () => {
};
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
serverA: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...localMcpServers['serverA'],
includeTools: ['local_tool'],
timeout: 1234,
@@ -1810,7 +1763,6 @@ describe('loadCliConfig with admin.mcp.config', () => {
};
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
serverA: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...localMcpServers['serverA'],
includeTools: ['local_tool'],
},
@@ -2273,30 +2225,6 @@ describe('loadCliConfig tool exclusions', () => {
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should exclude ask_user in interactive mode when --acp is provided', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--acp'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should exclude ask_user in interactive mode when --experimental-acp is provided', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--experimental-acp'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => {
process.stdin.isTTY = false;
process.argv = [
+3 -110
View File
@@ -4,11 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import yargs from 'yargs';
import yargs from 'yargs/yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import { execa } from 'execa';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
@@ -39,9 +38,6 @@ import {
applyAdminAllowlist,
applyRequiredServers,
getAdminBlockedMcpServersMessage,
getProjectRootForWorktree,
isGeminiWorktree,
type WorktreeSettings,
type HookDefinition,
type HookEventName,
type OutputFormat,
@@ -52,8 +48,6 @@ import {
type MergedSettings,
saveModelChange,
loadSettings,
isWorktreeEnabled,
type LoadedSettings,
} from './settings.js';
import { loadSandboxConfig } from './sandboxConfig.js';
@@ -80,7 +74,6 @@ export interface CliArgs {
debug: boolean | undefined;
prompt: string | undefined;
promptInteractive: string | undefined;
worktree?: string;
yolo: boolean | undefined;
approvalMode: string | undefined;
@@ -122,36 +115,6 @@ const coerceCommaSeparated = (values: string[]): string[] => {
);
};
/**
* Pre-parses the command line arguments to find the worktree flag.
* Used for early setup before full argument parsing with settings.
*/
export function getWorktreeArg(argv: string[]): string | undefined {
const result = yargs(hideBin(argv))
.help(false)
.version(false)
.option('worktree', { alias: 'w', type: 'string' })
.strict(false)
.exitProcess(false)
.parseSync();
if (result.worktree === undefined) return undefined;
return typeof result.worktree === 'string' ? result.worktree.trim() : '';
}
/**
* Checks if a worktree is requested via CLI and enabled in settings.
* Returns the requested name (can be empty string for auto-generated) or undefined.
*/
export function getRequestedWorktreeName(
settings: LoadedSettings,
): string | undefined {
if (!isWorktreeEnabled(settings)) {
return undefined;
}
return getWorktreeArg(process.argv);
}
export async function parseArguments(
settings: MergedSettings,
): Promise<CliArgs> {
@@ -195,20 +158,6 @@ export async function parseArguments(
description:
'Execute the provided prompt and continue in interactive mode',
})
.option('worktree', {
alias: 'w',
type: 'string',
skipValidation: true,
description:
'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.',
coerce: (value: unknown): string => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed === '') {
return Math.random().toString(36).substring(2, 10);
}
return trimmed;
},
})
.option('sandbox', {
alias: 's',
type: 'boolean',
@@ -386,9 +335,6 @@ export async function parseArguments(
) {
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
}
if (argv['worktree'] && !settings.experimental?.worktrees) {
return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.';
}
return true;
});
@@ -474,7 +420,6 @@ export interface LoadCliConfigOptions {
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
disabled?: string[];
};
worktreeSettings?: WorktreeSettings;
}
export async function loadCliConfig(
@@ -486,9 +431,6 @@ export async function loadCliConfig(
const { cwd = process.cwd(), projectHooks } = options;
const debugMode = isDebugMode(argv);
const worktreeSettings =
options.worktreeSettings ?? (await resolveWorktreeSettings(cwd));
if (argv.sandbox) {
process.env['GEMINI_SANDBOX'] = 'true';
}
@@ -707,16 +649,12 @@ export async function loadCliConfig(
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive || isAcpMode) {
if (!interactive) {
// The Policy Engine natively handles headless safety by translating ASK_USER
// decisions to DENY. However, we explicitly block ask_user here to guarantee
// it can never be allowed via a high-priority policy rule when no human is present.
// We also exclude it in ACP mode as IDEs intercept tool calls and ask for permission,
// breaking conversational flows.
extraExcludes.push(ASK_USER_TOOL_NAME);
}
@@ -832,6 +770,7 @@ export async function loadCliConfig(
}
}
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
let clientName: string | undefined = undefined;
if (isAcpMode) {
const ide = detectIdeFromEnv();
@@ -860,7 +799,6 @@ export async function loadCliConfig(
importFormat: settings.context?.importFormat,
debugMode,
question,
worktreeSettings,
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
@@ -1002,48 +940,3 @@ function mergeExcludeTools(
]);
return Array.from(allExcludeTools);
}
async function resolveWorktreeSettings(
cwd: string,
): Promise<WorktreeSettings | undefined> {
let worktreePath: string | undefined;
try {
const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], {
cwd,
});
const toplevel = stdout.trim();
const projectRoot = await getProjectRootForWorktree(toplevel);
if (isGeminiWorktree(toplevel, projectRoot)) {
worktreePath = toplevel;
}
} catch (_e) {
return undefined;
}
if (!worktreePath) {
return undefined;
}
let worktreeBaseSha: string | undefined;
try {
const { stdout } = await execa('git', ['rev-parse', 'HEAD'], {
cwd: worktreePath,
});
worktreeBaseSha = stdout.trim();
} catch (e: unknown) {
debugLogger.debug(
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
);
}
if (!worktreeBaseSha) {
return undefined;
}
return {
name: path.basename(worktreePath),
path: worktreePath,
baseSha: worktreeBaseSha,
};
}
@@ -637,4 +637,64 @@ describe('ExtensionManager', () => {
);
});
});
describe('orphaned extension cleanup', () => {
it('should remove broken extension metadata on startup to allow re-installation', async () => {
const extName = 'orphaned-ext';
const sourceDir = path.join(tempHomeDir, 'valid-source');
fs.mkdirSync(sourceDir, { recursive: true });
fs.writeFileSync(
path.join(sourceDir, 'gemini-extension.json'),
JSON.stringify({ name: extName, version: '1.0.0' }),
);
// Link an extension successfully.
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceDir,
type: 'link',
});
const destinationPath = path.join(userExtensionsDir, extName);
const metadataPath = path.join(
destinationPath,
'.gemini-extension-install.json',
);
expect(fs.existsSync(metadataPath)).toBe(true);
// Simulate metadata corruption (e.g., pointing to a non-existent source).
fs.writeFileSync(
metadataPath,
JSON.stringify({ source: '/NON_EXISTENT_PATH', type: 'link' }),
);
// Simulate CLI startup. The manager should detect the broken link
// and proactively delete the orphaned metadata directory.
const newManager = new ExtensionManager({
settings: createTestMergedSettings(),
workspaceDir: tempWorkspaceDir,
requestConsent: vi.fn().mockResolvedValue(true),
requestSetting: null,
integrityManager: mockIntegrityManager,
});
await newManager.loadExtensions();
// Verify the extension failed to load and was proactively cleaned up.
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
false,
);
expect(fs.existsSync(destinationPath)).toBe(false);
// Verify the system is self-healed and allows re-linking to the valid source.
await newManager.installOrUpdateExtension({
source: sourceDir,
type: 'link',
});
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
true,
);
});
});
});
+11 -4
View File
@@ -982,11 +982,18 @@ Would you like to attempt to install via "git clone" instead?`,
plan: config.plan,
};
} catch (e) {
debugLogger.error(
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
e,
)}`,
const extName = path.basename(extensionDir);
debugLogger.warn(
`Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`,
);
try {
await fs.promises.rm(extensionDir, { recursive: true, force: true });
} catch (rmError) {
debugLogger.error(
`Failed to remove broken extension directory ${extensionDir}:`,
rmError,
);
}
return null;
}
}
+10 -18
View File
@@ -249,10 +249,8 @@ describe('extension tests', () => {
expect(extensions[0].name).toBe('test-extension');
});
it('should skip the extension if a context file path is outside the extension directory and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should log a warning and remove the extension if a context file path is outside the extension directory', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'traversal-extension',
@@ -662,10 +660,8 @@ name = "yolo-checker"
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
});
it('should skip an extension with invalid JSON config and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should remove an extension with invalid JSON config and log a warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Good extension
createExtension({
@@ -686,17 +682,15 @@ name = "yolo-checker"
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
`Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`,
),
);
consoleSpy.mockRestore();
});
it('should skip an extension with missing "name" in config and log an error', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should remove an extension with missing "name" in config and log a warning', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Good extension
createExtension({
@@ -717,7 +711,7 @@ name = "yolo-checker"
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
`Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
);
@@ -743,10 +737,8 @@ name = "yolo-checker"
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
});
it('should log an error for invalid extension names during loading', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
it('should log a warning for invalid extension names during loading', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'bad_name',
@@ -59,9 +59,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
async function expectConsentSnapshot(consentString: string) {
const renderResult = await render(
React.createElement(Text, null, consentString),
);
const renderResult = render(React.createElement(Text, null, consentString));
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
}
@@ -13,7 +13,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...actual,
Storage: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...actual.Storage,
getGlobalGeminiDir: () => '/virtual-home/.gemini',
},
-4
View File
@@ -632,10 +632,6 @@ export function resetSettingsCacheForTesting() {
settingsCache.clear();
}
export function isWorktreeEnabled(settings: LoadedSettings): boolean {
return settings.merged.experimental.worktrees;
}
/**
* Loads settings from user and workspace directories.
* Project settings override user settings.
@@ -538,32 +538,8 @@ describe('SettingsSchema', () => {
}
};
const visitJsonSchema = (jsonSchema: Record<string, unknown>) => {
const ref = jsonSchema['ref'];
if (typeof ref === 'string') {
referenced.add(ref);
}
const properties = jsonSchema['properties'];
if (
properties &&
typeof properties === 'object' &&
!Array.isArray(properties)
) {
Object.values(properties as Record<string, unknown>).forEach((prop) =>
visitJsonSchema(prop as Record<string, unknown>),
);
}
const items = jsonSchema['items'];
if (items && typeof items === 'object' && !Array.isArray(items)) {
visitJsonSchema(items as Record<string, unknown>);
}
};
Object.values(schema).forEach(visitDefinition);
// Also visit all definitions to find nested references
Object.values(SETTINGS_SCHEMA_DEFINITIONS).forEach(visitJsonSchema);
// Ensure definitions map doesn't accumulate stale entries.
Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => {
if (!referenced.has(key)) {
+1 -39
View File
@@ -1094,7 +1094,7 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
additionalProperties: {
type: 'array',
ref: 'ModelPolicyChain',
ref: 'ModelPolicy',
},
},
},
@@ -1198,26 +1198,6 @@ const SETTINGS_SCHEMA = {
'Disable user input on browser window during automation.',
showInDialog: false,
},
confirmSensitiveActions: {
type: 'boolean',
label: 'Confirm Sensitive Actions',
category: 'Advanced',
requiresRestart: true,
default: false,
description:
'Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script).',
showInDialog: true,
},
blockFileUploads: {
type: 'boolean',
label: 'Block File Uploads',
category: 'Advanced',
requiresRestart: true,
default: false,
description:
'Hard-block file upload requests from the browser agent.',
showInDialog: true,
},
},
},
},
@@ -1926,16 +1906,6 @@ const SETTINGS_SCHEMA = {
description: 'Enable local and remote subagents.',
showInDialog: false,
},
worktrees: {
type: 'boolean',
label: 'Enable Git Worktrees',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable automated Git worktree management for parallel work.',
showInDialog: true,
},
extensionManagement: {
type: 'boolean',
label: 'Extension Management',
@@ -3018,14 +2988,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
ModelPolicyChain: {
type: 'array',
description: 'A chain of model policies for fallback behavior.',
items: {
type: 'object',
ref: 'ModelPolicy',
},
},
ModelPolicy: {
type: 'object',
description:
-4
View File
@@ -126,7 +126,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
clearInstance: vi.fn(),
},
coreEvents: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...actual.coreEvents,
emitFeedback: vi.fn(),
emitConsoleLog: vi.fn(),
@@ -200,8 +199,6 @@ vi.mock('./config/config.js', () => ({
networkAccess: false,
}),
isDebugMode: vi.fn(() => false),
getRequestedWorktreeName: vi.fn(() => undefined),
getWorktreeArg: vi.fn(() => undefined),
}));
vi.mock('read-package-up', () => ({
@@ -1509,7 +1506,6 @@ describe('startInteractiveUI', () => {
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
const mockConfigWithScreenReader = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getScreenReader: () => screenReader,
} as Config;
+15 -43
View File
@@ -9,7 +9,6 @@ import {
WarningPriority,
type Config,
type ResumedSessionData,
type WorktreeInfo,
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
@@ -32,7 +31,6 @@ import {
ValidationRequiredError,
type AdminControlsSettings,
debugLogger,
isHeadlessMode,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments } from './config/config.js';
@@ -65,7 +63,6 @@ import {
registerTelemetryConfig,
setupSignalHandlers,
} from './utils/cleanup.js';
import { setupWorktree } from './utils/worktreeSetup.js';
import {
cleanupToolOutputFiles,
cleanupExpiredSessions,
@@ -213,37 +210,6 @@ export async function main() {
const settings = loadSettings();
loadSettingsHandle?.end();
// If a worktree is requested and enabled, set it up early.
// This must be awaited before any other async tasks that depend on CWD (like loadCliConfig)
// because setupWorktree calls process.chdir().
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
let worktreeInfo: WorktreeInfo | undefined;
if (requestedWorktree !== undefined) {
const worktreeHandle = startupProfiler.start('setup_worktree');
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
worktreeHandle?.end();
}
const cleanupOpsHandle = startupProfiler.start('cleanup_ops');
Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
])
.catch((e) => {
debugLogger.error('Early cleanup failed:', e);
})
.finally(() => {
cleanupOpsHandle?.end();
});
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argvPromise = parseArguments(settings.merged).finally(() => {
parseArgsHandle?.end();
});
const rawStartupWarningsPromise = getStartupWarnings();
// Report settings errors once during startup
settings.errors.forEach((error) => {
coreEvents.emitFeedback('warning', error.message);
@@ -257,7 +223,15 @@ export async function main() {
);
});
const argv = await argvPromise;
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
]);
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argv = await parseArguments(settings.merged);
parseArgsHandle?.end();
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
@@ -297,7 +271,6 @@ export async function main() {
const isDebugMode = cliConfig.isDebugMode(argv);
const consolePatcher = new ConsolePatcher({
stderr: true,
interactive: isHeadlessMode() ? false : true,
debugMode: isDebugMode,
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
@@ -453,7 +426,6 @@ export async function main() {
const loadConfigHandle = startupProfiler.start('load_cli_config');
const config = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
worktreeSettings: worktreeInfo,
});
loadConfigHandle?.end();
@@ -485,10 +457,12 @@ export async function main() {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
// Launch cleanup expired sessions as a background task
cleanupExpiredSessions(config, settings.merged).catch((e) => {
// Cleanup sessions after config initialization
try {
await cleanupExpiredSessions(config, settings.merged);
} catch (e) {
debugLogger.error('Failed to cleanup expired sessions:', e);
});
}
if (config.getListExtensions()) {
debugLogger.log('Installed extensions:');
@@ -540,9 +514,7 @@ export async function main() {
});
}
const terminalHandle = startupProfiler.start('setup_terminal');
await setupTerminalAndTheme(config, settings);
terminalHandle?.end();
const initAppHandle = startupProfiler.start('initialize_app');
const initializationResult = await initializeApp(config, settings);
@@ -566,7 +538,7 @@ export async function main() {
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const rawStartupWarnings = await rawStartupWarningsPromise;
const rawStartupWarnings = await getStartupWarnings();
const startupWarnings: StartupWarning[] = [
...rawStartupWarnings.map((message) => ({
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
-2
View File
@@ -72,8 +72,6 @@ vi.mock('./config/config.js', () => ({
} as unknown as Config),
parseArguments: vi.fn().mockResolvedValue({}),
isDebugMode: vi.fn(() => false),
getRequestedWorktreeName: vi.fn(() => undefined),
getWorktreeArg: vi.fn(() => undefined),
}));
vi.mock('read-package-up', () => ({
@@ -1137,7 +1137,6 @@ describe('runNonInteractive', () => {
expect(
processStderrSpy.mock.calls.some(
// eslint-disable-next-line no-restricted-syntax
(call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
),
).toBe(true);
-1
View File
@@ -65,7 +65,6 @@ export async function runNonInteractive({
return promptIdContext.run(prompt_id, async () => {
const consolePatcher = new ConsolePatcher({
stderr: true,
interactive: false,
debugMode: config.getDebugMode(),
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
@@ -266,7 +266,6 @@ describe('BuiltinCommandLoader', () => {
it('should include policies command when message bus integration is enabled', async () => {
const mockConfigWithMessageBus = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getEnableHooks: () => false,
getMcpEnabled: () => true,
+2 -12
View File
@@ -11,11 +11,7 @@ import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { AppContainer } from '../ui/AppContainer.js';
import {
renderWithProviders,
type RenderInstance,
persistentStateMock,
} from './render.js';
import { renderWithProviders, type RenderInstance } from './render.js';
import {
makeFakeConfig,
type Config,
@@ -184,11 +180,6 @@ export class AppRig {
}
async initialize() {
persistentStateMock.setData({
terminalSetupPromptShown: true,
tipsShown: 10,
});
this.setupEnvironment();
resetSettingsCacheForTesting();
this.settings = this.createRigSettings();
@@ -235,8 +226,6 @@ export class AppRig {
private setupEnvironment() {
// Stub environment variables to avoid interference from developer's machine
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
vi.stubEnv('TERM_PROGRAM', 'other');
vi.stubEnv('VSCODE_GIT_IPC_HANDLE', '');
if (this.options.fakeResponsesPath) {
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
MockShellExecutionService.setPassthrough(false);
@@ -302,6 +291,7 @@ export class AppRig {
const newContentGeneratorConfig = {
authType: authMethod,
proxy: gcConfig.getProxy(),
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
};
@@ -79,7 +79,7 @@ export async function toMatchSvgSnapshot(
}
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
const { isNot } = this as any;
let pass = true;
const invalidLines: Array<{ line: number; content: string }> = [];
@@ -108,6 +108,7 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
};
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
expect.extend({
toHaveOnlyValidCharacters,
toMatchSvgSnapshot,
@@ -37,12 +37,14 @@ export const createMockCommandContext = (
},
services: {
agentContext: null,
settings: {
merged: defaultMergedSettings,
setValue: vi.fn(),
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
logger: {
log: vi.fn(),
logMessage: vi.fn(),
@@ -51,6 +53,7 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, // Cast because Logger is a class.
},
ui: {
addItem: vi.fn(),
clear: vi.fn(),
@@ -69,6 +72,7 @@ export const createMockCommandContext = (
} as any,
session: {
sessionShellAllowlist: new Set<string>(),
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
@@ -94,6 +98,7 @@ export const createMockCommandContext = (
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const sourceValue = source[key];
const targetValue = output[key];
if (
@@ -104,6 +109,7 @@ export const createMockCommandContext = (
output[key] = merge(targetValue, sourceValue);
} else {
// If not, we do a direct assignment. This preserves Date objects and others.
output[key] = sourceValue;
}
}
@@ -44,7 +44,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getDeleteSession: vi.fn(() => undefined),
setSessionId: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getWorktreeSettings: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
getAcpMode: vi.fn(() => false),
isBrowserLaunchSuppressed: vi.fn(() => false),
+39 -14
View File
@@ -12,18 +12,24 @@ import { waitFor } from './async.js';
describe('render', () => {
it('should render a component', async () => {
const { lastFrame, unmount } = await render(<Text>Hello World</Text>);
const { lastFrame, waitUntilReady, unmount } = render(
<Text>Hello World</Text>,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello World\n');
unmount();
});
it('should support rerender', async () => {
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
const { lastFrame, rerender, waitUntilReady, unmount } = render(
<Text>Hello</Text>,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello\n');
await act(async () => rerender(<Text>World</Text>));
await act(async () => {
rerender(<Text>World</Text>);
});
await waitUntilReady();
expect(lastFrame()).toBe('World\n');
unmount();
@@ -36,8 +42,10 @@ describe('render', () => {
return <Text>Hello</Text>;
}
const { unmount } = await render(<TestComponent />);
const { unmount, waitUntilReady } = render(<TestComponent />);
await waitUntilReady();
unmount();
expect(cleanupMock).toHaveBeenCalled();
});
});
@@ -46,27 +54,36 @@ describe('renderHook', () => {
it('should rerender with previous props when called without arguments', async () => {
const useTestHook = ({ value }: { value: number }) => {
const [count, setCount] = useState(0);
useEffect(() => setCount((c) => c + 1), [value]);
useEffect(() => {
setCount((c) => c + 1);
}, [value]);
return { count, value };
};
const { result, rerender, waitUntilReady, unmount } = await renderHook(
const { result, rerender, waitUntilReady, unmount } = renderHook(
useTestHook,
{ initialProps: { value: 1 } },
{
initialProps: { value: 1 },
},
);
await waitUntilReady();
expect(result.current.value).toBe(1);
await waitFor(() => expect(result.current.count).toBe(1));
// Rerender with new props
await act(async () => rerender({ value: 2 }));
await act(async () => {
rerender({ value: 2 });
});
await waitUntilReady();
expect(result.current.value).toBe(2);
await waitFor(() => expect(result.current.count).toBe(2));
// Rerender without arguments should use previous props (value: 2)
// This would previously crash or pass undefined if not fixed
await act(async () => rerender());
await act(async () => {
rerender();
});
await waitUntilReady();
expect(result.current.value).toBe(2);
// Count should not increase because value didn't change
@@ -81,11 +98,14 @@ describe('renderHook', () => {
};
const { result, rerender, waitUntilReady, unmount } =
await renderHook(useTestHook);
renderHook(useTestHook);
await waitUntilReady();
expect(result.current.count).toBe(0);
await act(async () => rerender());
await act(async () => {
rerender();
});
await waitUntilReady();
expect(result.current.count).toBe(0);
unmount();
@@ -93,14 +113,19 @@ describe('renderHook', () => {
it('should update props if undefined is passed explicitly', async () => {
const useTestHook = (val: string | undefined) => val;
const { result, rerender, waitUntilReady, unmount } = await renderHook(
const { result, rerender, waitUntilReady, unmount } = renderHook(
useTestHook,
{ initialProps: 'initial' },
{
initialProps: 'initial' as string | undefined,
},
);
await waitUntilReady();
expect(result.current).toBe('initial');
await act(async () => rerender(undefined));
await act(async () => {
rerender(undefined);
});
await waitUntilReady();
expect(result.current).toBeUndefined();
unmount();
+40 -36
View File
@@ -257,9 +257,13 @@ class XtermStdout extends EventEmitter {
return currentFrame !== '';
}
// If Ink expects nothing (no new static content and no dynamic output),
// we consider it a match because the terminal buffer will just hold the historical static content.
if (expectedFrame === '') {
// If both are empty, it's a match.
// We consider undefined lastRenderOutput as effectively empty for this check
// to support hook testing where Ink may skip rendering completely.
if (
(this.lastRenderOutput === undefined || expectedFrame === '') &&
currentFrame === ''
) {
return true;
}
@@ -267,8 +271,8 @@ class XtermStdout extends EventEmitter {
return false;
}
// If the terminal is empty but Ink expects something, it's not a match.
if (currentFrame === '') {
// If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match.
if (expectedFrame === '' || currentFrame === '') {
return false;
}
@@ -376,21 +380,15 @@ export type RenderInstance = {
capturedOverflowActions: OverflowActions | undefined;
};
export type RenderWithProvidersInstance = RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
};
const instances: InkInstance[] = [];
export const render = async (
// Wrapper around ink's render that ensures act() is called and uses Xterm for output
export const render = (
tree: React.ReactElement,
terminalWidth?: number,
): Promise<
Omit<RenderInstance, 'capturedOverflowState' | 'capturedOverflowActions'>
): Omit<
RenderInstance,
'capturedOverflowState' | 'capturedOverflowActions'
> => {
const cols = terminalWidth ?? 100;
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
@@ -439,8 +437,6 @@ export const render = async (
instances.push(instance);
await stdout.waitUntilReady();
return {
rerender: (newTree: React.ReactElement) => {
act(() => {
@@ -626,7 +622,15 @@ export const renderWithProviders = async (
};
appState?: AppState;
} = {},
): Promise<RenderWithProvidersInstance> => {
): Promise<
RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
}
> => {
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
{
@@ -747,10 +751,7 @@ export const renderWithProviders = async (
</AppContext.Provider>
);
const renderResult = await render(
wrapWithProviders(component),
terminalWidth,
);
const renderResult = render(wrapWithProviders(component), terminalWidth);
return {
...renderResult,
@@ -764,20 +765,21 @@ export const renderWithProviders = async (
};
};
export async function renderHook<Result, Props>(
export function renderHook<Result, Props>(
renderCallback: (props: Props) => Result,
options?: {
initialProps?: Props;
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
},
): Promise<{
): {
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
generateSvg: () => string;
}> {
} {
const result = { current: undefined as unknown as Result };
let currentProps = options?.initialProps as Props;
function TestComponent({
@@ -798,15 +800,17 @@ export async function renderHook<Result, Props>(
let waitUntilReady: () => Promise<void> = async () => {};
let generateSvg: () => string = () => '';
const renderResult = await render(
<Wrapper>
<TestComponent renderCallback={renderCallback} props={currentProps} />
</Wrapper>,
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
generateSvg = renderResult.generateSvg;
act(() => {
const renderResult = render(
<Wrapper>
<TestComponent renderCallback={renderCallback} props={currentProps} />
</Wrapper>,
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
generateSvg = renderResult.generateSvg;
});
function rerender(props?: Props) {
if (arguments.length > 0) {
@@ -860,7 +864,7 @@ export async function renderHookWithProviders<Result, Props>(
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
let renderResult: RenderWithProvidersInstance;
let renderResult: ReturnType<typeof render>;
await act(async () => {
renderResult = await renderWithProviders(
+2
View File
@@ -46,6 +46,7 @@ export const createMockSettings = (
workspace,
isTrusted,
errors,
merged: mergedOverride,
...settingsOverrides
} = overrides;
@@ -60,6 +61,7 @@ export const createMockSettings = (
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
+89 -45
View File
@@ -94,10 +94,14 @@ describe('App', () => {
};
it('should render main content and composer when not quitting', async () => {
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -111,10 +115,14 @@ describe('App', () => {
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Quitting...');
unmount();
@@ -128,10 +136,14 @@ describe('App', () => {
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('HistoryItemDisplay');
expect(lastFrame()).toContain('Quitting...');
@@ -144,10 +156,14 @@ describe('App', () => {
dialogsVisible: true,
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -167,10 +183,14 @@ describe('App', () => {
[stateKey]: true,
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
unmount();
@@ -180,10 +200,14 @@ describe('App', () => {
it('should render ScreenReaderAppLayout when screen reader is enabled', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Footer');
@@ -195,10 +219,14 @@ describe('App', () => {
it('should render DefaultAppLayout when screen reader is not enabled', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -246,11 +274,15 @@ describe('App', () => {
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: stateWithConfirmingTool,
config: configWithExperiment,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: stateWithConfirmingTool,
config: configWithExperiment,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -263,20 +295,28 @@ describe('App', () => {
describe('Snapshots', () => {
it('renders default layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders screen reader layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -286,10 +326,14 @@ describe('App', () => {
...mockUIState,
dialogsVisible: true,
} as UIState;
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
File diff suppressed because it is too large Load Diff
@@ -42,7 +42,6 @@ describe('IdeIntegrationNudge', () => {
beforeEach(() => {
vi.mocked(debugLogger.warn).mockImplementation((...args) => {
if (
// eslint-disable-next-line no-restricted-syntax
typeof args[0] === 'string' &&
/was not wrapped in act/.test(args[0])
) {
@@ -54,9 +53,10 @@ describe('IdeIntegrationNudge', () => {
});
it('renders correctly with default options', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<IdeIntegrationNudge {...defaultProps} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
@@ -72,6 +72,8 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// "Yes" is the first option and selected by default usually.
await act(async () => {
stdin.write('\r');
@@ -91,6 +93,8 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Navigate down to "No (esc)"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
@@ -115,6 +119,8 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Navigate down to "No, don't ask again"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
@@ -144,6 +150,8 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Press Escape
await act(async () => {
stdin.write('\u001B');
@@ -170,6 +178,8 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain(
@@ -2,13 +2,10 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Tips for getting started:
@@ -34,6 +31,9 @@ Tips for getting started:
@@ -47,13 +47,10 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
"Notifications
Footer
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Tips for getting started:
@@ -67,12 +64,12 @@ Composer
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Gemini CLI v1.2.3
@@ -110,13 +107,10 @@ DialogManager
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Tips for getting started:
@@ -146,6 +140,9 @@ HistoryItemDisplay
Notifications
Composer
"
@@ -73,21 +73,23 @@ describe('ApiAuthDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with a defaultValue', async () => {
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
defaultValue="test-key"
/>,
);
await waitUntilReady();
expect(mockedUseTextBuffer).toHaveBeenCalledWith(
expect.objectContaining({
initialText: 'test-key',
@@ -111,9 +113,10 @@ describe('ApiAuthDialog', () => {
'calls $expectedCall.name when $keyName is pressed',
async ({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
// calls[1] is the TextInput's useKeypress (typing handler)
const keypressHandler = mockedUseKeypress.mock.calls[1][0];
@@ -133,22 +136,24 @@ describe('ApiAuthDialog', () => {
);
it('displays an error message', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
error="Invalid API Key"
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Invalid API Key');
unmount();
});
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// Call 0 is ApiAuthDialog (isActive: true)
// Call 1 is TextInput (isActive: true, priority: true)
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
+58 -17
View File
@@ -143,9 +143,10 @@ describe('AuthDialog', () => {
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value as string);
}
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
for (const item of shouldContain) {
expect(items).toContainEqual(item);
@@ -160,7 +161,10 @@ describe('AuthDialog', () => {
it('filters auth types when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(1);
expect(items[0].value).toBe(AuthType.USE_GEMINI);
@@ -169,7 +173,10 @@ describe('AuthDialog', () => {
it('sets initial index to 0 when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(initialIndex).toBe(0);
unmount();
@@ -206,7 +213,10 @@ describe('AuthDialog', () => {
},
])('selects initial auth type $desc', async ({ setup, expected }) => {
setup();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(expected);
unmount();
@@ -216,7 +226,10 @@ describe('AuthDialog', () => {
describe('handleAuthSelect', () => {
it('calls onAuthError if validation fails', async () => {
mockedValidateAuthMethod.mockReturnValue('Invalid method');
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
handleAuthSelect(AuthType.USE_GEMINI);
@@ -232,7 +245,10 @@ describe('AuthDialog', () => {
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
@@ -245,7 +261,10 @@ describe('AuthDialog', () => {
it('sets auth context with empty object for other auth types', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -259,7 +278,10 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -275,7 +297,10 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -291,7 +316,10 @@ describe('AuthDialog', () => {
// process.env['GEMINI_API_KEY'] is not set
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -309,7 +337,10 @@ describe('AuthDialog', () => {
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -329,7 +360,10 @@ describe('AuthDialog', () => {
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -349,9 +383,10 @@ describe('AuthDialog', () => {
it('displays authError when provided', async () => {
props.authError = 'Something went wrong';
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Something went wrong');
unmount();
});
@@ -394,7 +429,10 @@ describe('AuthDialog', () => {
},
])('$desc', async ({ setup, expectations }) => {
setup();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expectations(props);
@@ -404,27 +442,30 @@ describe('AuthDialog', () => {
describe('Snapshots', () => {
it('renders correctly with default props', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with auth error', async () => {
props.authError = 'Something went wrong';
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with enforced auth type', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -42,7 +42,6 @@ describe('AuthInProgress', () => {
vi.useFakeTimers();
vi.mocked(debugLogger.error).mockImplementation((...args) => {
if (
// eslint-disable-next-line no-restricted-syntax
typeof args[0] === 'string' &&
args[0].includes('was not wrapped in act')
) {
@@ -56,18 +55,20 @@ describe('AuthInProgress', () => {
});
it('renders initial state with spinner', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('[Spinner] Waiting for authentication...');
expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel');
unmount();
});
it('calls onTimeout when ESC is pressed', async () => {
const { waitUntilReady, unmount } = await render(
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
await act(async () => {
@@ -83,9 +84,10 @@ describe('AuthInProgress', () => {
});
it('calls onTimeout when Ctrl+C is pressed', async () => {
const { waitUntilReady, unmount } = await render(
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
await act(async () => {
@@ -98,9 +100,10 @@ describe('AuthInProgress', () => {
});
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
const { lastFrame, waitUntilReady, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
vi.advanceTimersByTime(180000);
@@ -113,7 +116,10 @@ describe('AuthInProgress', () => {
});
it('clears timer on unmount', async () => {
const { unmount } = await render(<AuthInProgress onTimeout={onTimeout} />);
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
unmount();
@@ -73,13 +73,14 @@ describe('BannedAccountDialog', () => {
});
it('renders the suspension message from accountSuspensionInfo', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Account Suspended');
expect(frame).toContain('violation of Terms of Service');
@@ -88,13 +89,14 @@ describe('BannedAccountDialog', () => {
});
it('renders menu options with appeal link text from response', async () => {
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(3);
expect(items[0].label).toBe('Appeal Here');
@@ -107,13 +109,14 @@ describe('BannedAccountDialog', () => {
const infoWithoutUrl: AccountSuspensionInfo = {
message: 'Account suspended.',
};
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutUrl}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(2);
expect(items[0].label).toBe('Change authentication');
@@ -126,26 +129,28 @@ describe('BannedAccountDialog', () => {
message: 'Account suspended.',
appealUrl: 'https://example.com/appeal',
};
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutLinkText}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items[0].label).toBe('Open the Google Form');
unmount();
});
it('opens browser when appeal option is selected', async () => {
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await onSelect('open_form');
expect(mockedOpenBrowser).toHaveBeenCalledWith(
@@ -157,13 +162,14 @@ describe('BannedAccountDialog', () => {
it('shows URL when browser cannot be launched', async () => {
mockedShouldLaunchBrowser.mockReturnValue(false);
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
onSelect('open_form');
await waitFor(() => {
@@ -174,13 +180,14 @@ describe('BannedAccountDialog', () => {
});
it('calls onExit when "Exit" is selected', async () => {
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await onSelect('exit');
expect(mockedRunExitCleanup).toHaveBeenCalled();
@@ -189,13 +196,14 @@ describe('BannedAccountDialog', () => {
});
it('calls onChangeAuth when "Change authentication" is selected', async () => {
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
onSelect('change_auth');
expect(onChangeAuth).toHaveBeenCalled();
@@ -204,13 +212,14 @@ describe('BannedAccountDialog', () => {
});
it('exits on escape key', async () => {
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
const result = keypressHandler({ name: 'escape' });
expect(result).toBe(true);
@@ -218,13 +227,14 @@ describe('BannedAccountDialog', () => {
});
it('renders snapshot correctly', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -45,23 +45,25 @@ describe('LoginWithGoogleRestartDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('calls onDismiss when escape is pressed', async () => {
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
@@ -81,12 +83,13 @@ describe('LoginWithGoogleRestartDialog', () => {
async (keyName) => {
vi.useFakeTimers();
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
+90 -113
View File
@@ -4,8 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
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 {
@@ -15,6 +22,7 @@ import {
} from '@google/gemini-cli-core';
import { AuthState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { waitFor } from '../../test-utils/async.js';
// Mock dependencies
const mockLoadApiKey = vi.fn();
@@ -134,202 +142,171 @@ describe('useAuth', () => {
},
}) as LoadedSettings;
let deferredRefreshAuth: {
resolve: () => void;
reject: (e: Error) => void;
};
beforeEach(() => {
vi.mocked(mockConfig.refreshAuth).mockImplementation(
() =>
new Promise((resolve, reject) => {
deferredRefreshAuth = { resolve, reject };
}),
);
});
it('should initialize with Unauthenticated state', async () => {
const { result } = await renderHook(() =>
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
// Because we defer refreshAuth, the initial state is safely caught here
expect(result.current.authState).toBe(AuthState.Unauthenticated);
await act(async () => {
deferredRefreshAuth.resolve();
await waitFor(() => {
expect(result.current.authState).toBe(AuthState.Authenticated);
});
expect(result.current.authState).toBe(AuthState.Authenticated);
});
it('should set error if no auth type is selected and no env key', async () => {
const { result } = await renderHook(() =>
const { result } = renderHook(() =>
useAuthCommand(createSettings(undefined), mockConfig),
);
// This happens synchronously, no deferred promise
expect(result.current.authError).toBe(
'No authentication method selected.',
);
expect(result.current.authState).toBe(AuthState.Updating);
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 } = await renderHook(() =>
const { result } = renderHook(() =>
useAuthCommand(createSettings(undefined), mockConfig),
);
expect(result.current.authError).toContain(
'Existing API key detected (GEMINI_API_KEY)',
);
expect(result.current.authState).toBe(AuthState.Updating);
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 () => {
let deferredLoadKey: { resolve: (k: string | null) => void };
mockLoadApiKey.mockImplementation(
() =>
new Promise((resolve) => {
deferredLoadKey = { resolve };
}),
);
const { result } = await renderHook(() =>
mockLoadApiKey.mockResolvedValue(null);
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
);
await act(async () => {
deferredLoadKey.resolve(null);
await waitFor(() => {
expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput);
});
expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput);
});
it('should authenticate if USE_GEMINI and key is found', async () => {
let deferredLoadKey: { resolve: (k: string | null) => void };
mockLoadApiKey.mockImplementation(
() =>
new Promise((resolve) => {
deferredLoadKey = { resolve };
}),
);
const { result } = await renderHook(() =>
mockLoadApiKey.mockResolvedValue('stored-key');
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
);
await act(async () => {
deferredLoadKey.resolve('stored-key');
await waitFor(() => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.apiKeyDefaultValue).toBe('stored-key');
});
await act(async () => {
deferredRefreshAuth.resolve();
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.apiKeyDefaultValue).toBe('stored-key');
});
it('should authenticate if USE_GEMINI and env key is found', async () => {
mockLoadApiKey.mockResolvedValue(null);
process.env['GEMINI_API_KEY'] = 'env-key';
const { result } = await renderHook(() =>
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
);
await act(async () => {
deferredRefreshAuth.resolve();
await waitFor(() => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.apiKeyDefaultValue).toBe('env-key');
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.apiKeyDefaultValue).toBe('env-key');
});
it('should prioritize env key over stored key when both are present', async () => {
mockLoadApiKey.mockResolvedValue('stored-key');
process.env['GEMINI_API_KEY'] = 'env-key';
const { result } = await renderHook(() =>
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
);
await act(async () => {
deferredRefreshAuth.resolve();
await waitFor(() => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
);
expect(result.current.authState).toBe(AuthState.Authenticated);
// The environment key should take precedence
expect(result.current.apiKeyDefaultValue).toBe('env-key');
});
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 } = await renderHook(() =>
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
expect(result.current.authError).toBe('Validation Failed');
expect(result.current.authState).toBe(AuthState.Updating);
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 } = await renderHook(() =>
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
expect(result.current.authError).toContain(
'Invalid value for GEMINI_DEFAULT_AUTH_TYPE',
);
expect(result.current.authState).toBe(AuthState.Updating);
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 } = await renderHook(() =>
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
await act(async () => {
deferredRefreshAuth.resolve();
await waitFor(() => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.authError).toBeNull();
});
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 () => {
const { result } = await renderHook(() =>
(mockConfig.refreshAuth as Mock).mockRejectedValue(
new Error('Auth Failed'),
);
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
await act(async () => {
deferredRefreshAuth.reject(new Error('Auth Failed'));
await waitFor(() => {
expect(result.current.authError).toContain('Failed to sign in');
expect(result.current.authState).toBe(AuthState.Updating);
});
expect(result.current.authError).toContain('Failed to sign in');
expect(result.current.authState).toBe(AuthState.Updating);
});
it('should handle ProjectIdRequiredError without "Failed to login" prefix', async () => {
const projectIdError = new ProjectIdRequiredError();
const { result } = await renderHook(() =>
(mockConfig.refreshAuth as Mock).mockRejectedValue(projectIdError);
const { result } = renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
await act(async () => {
deferredRefreshAuth.reject(projectIdError);
await waitFor(() => {
expect(result.current.authError).toBe(
'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca',
);
expect(result.current.authError).not.toContain('Failed to login');
expect(result.current.authState).toBe(AuthState.Updating);
});
expect(result.current.authError).toBe(
'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca',
);
expect(result.current.authError).not.toContain('Failed to login');
expect(result.current.authState).toBe(AuthState.Updating);
});
});
});
@@ -710,14 +710,10 @@ describe('extensionsCommand', () => {
size: 100,
} as Stats);
await linkAction!(mockContext, packageName);
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: packageName,
type: 'link',
},
undefined,
undefined,
);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'link',
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.INFO,
text: `Linking extension from "${packageName}"...`,
@@ -737,14 +733,10 @@ describe('extensionsCommand', () => {
} as Stats);
await linkAction!(mockContext, packageName);
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: packageName,
type: 'link',
},
undefined,
undefined,
);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'link',
});
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.ERROR,
text: `Failed to link extension from "${packageName}": ${errorMessage}`,
@@ -286,11 +286,6 @@ async function exploreAction(
await installAction(context, extension.url, requestConsentOverride);
context.ui.removeComponent();
},
onLink: async (extension, requestConsentOverride) => {
debugLogger.log(`Linking extension: ${extension.extensionName}`);
await linkAction(context, extension.url, requestConsentOverride);
context.ui.removeComponent();
},
onClose: () => context.ui.removeComponent(),
extensionManager,
}),
@@ -538,11 +533,7 @@ async function installAction(
}
}
async function linkAction(
context: CommandContext,
args: string,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) {
async function linkAction(context: CommandContext, args: string) {
const extensionLoader =
context.services.agentContext?.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
@@ -591,11 +582,8 @@ async function linkAction(
source: sourceFilepath,
type: 'link',
};
const extension = await extensionLoader.installOrUpdateExtension(
installMetadata,
undefined,
requestConsentOverride,
);
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
context.ui.addItem({
type: MessageType.INFO,
text: `Extension "${extension.name}" linked successfully.`,
@@ -38,7 +38,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...actual,
coreEvents: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...actual.coreEvents,
emitFeedback: vi.fn(),
},
@@ -25,9 +25,10 @@ describe('AboutBox', () => {
};
it('renders with required props', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AboutBox {...defaultProps} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('About Gemini CLI');
expect(output).toContain('1.0.0');
@@ -45,9 +46,10 @@ describe('AboutBox', () => {
['tier', 'Enterprise', 'Tier'],
])('renders optional prop %s', async (prop, value, label) => {
const props = { ...defaultProps, [prop]: value };
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(label);
expect(output).toContain(value);
@@ -56,9 +58,10 @@ describe('AboutBox', () => {
it('renders Auth Method with email when userEmail is provided', async () => {
const props = { ...defaultProps, userEmail: 'test@example.com' };
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Signed in with Google (test@example.com)');
unmount();
@@ -66,9 +69,10 @@ describe('AboutBox', () => {
it('renders Auth Method correctly when not oauth', async () => {
const props = { ...defaultProps, selectedAuthType: 'api-key' };
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('api-key');
unmount();
@@ -17,14 +17,15 @@ describe('AdminSettingsChangedDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame } = await renderWithProviders(
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AdminSettingsChangedDialog />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('restarts on "r" key press', async () => {
const { stdin } = await renderWithProviders(
const { stdin, waitUntilReady } = await renderWithProviders(
<AdminSettingsChangedDialog />,
{
uiActions: {
@@ -32,6 +33,7 @@ describe('AdminSettingsChangedDialog', () => {
},
},
);
await waitUntilReady();
act(() => {
stdin.write('r');
@@ -41,7 +43,7 @@ describe('AdminSettingsChangedDialog', () => {
});
it.each(['r', 'R'])('restarts on "%s" key press', async (key) => {
const { stdin } = await renderWithProviders(
const { stdin, waitUntilReady } = await renderWithProviders(
<AdminSettingsChangedDialog />,
{
uiActions: {
@@ -49,6 +51,7 @@ describe('AdminSettingsChangedDialog', () => {
},
},
);
await waitUntilReady();
act(() => {
stdin.write(key);
@@ -126,6 +126,7 @@ describe('AgentConfigDialog', () => {
/>,
{ settings, uiState: { mainAreaWidth: 100 } },
);
await result.waitUntilReady();
return result;
};
@@ -108,7 +108,7 @@ describe('AlternateBufferQuittingDisplay', () => {
it('renders with active and pending tool messages', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -118,13 +118,14 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_history_and_pending');
unmount();
});
it('renders with empty history and no pending items', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -134,13 +135,14 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('empty');
unmount();
});
it('renders with history but no pending items', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -150,13 +152,14 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_history_no_pending');
unmount();
});
it('renders with pending items but no history', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -166,6 +169,7 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_pending_no_history');
unmount();
});
@@ -191,7 +195,7 @@ describe('AlternateBufferQuittingDisplay', () => {
],
},
];
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -201,6 +205,7 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Action Required (was prompted):');
expect(output).toContain('confirming_tool');
@@ -215,7 +220,7 @@ describe('AlternateBufferQuittingDisplay', () => {
{ id: 1, type: 'user', text: 'Hello Gemini' },
{ id: 2, type: 'gemini', text: 'Hello User!' },
];
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -225,6 +230,7 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');
unmount();
});
@@ -29,9 +29,10 @@ describe('<AnsiOutputText />', () => {
createAnsiToken({ text: 'world!' }),
],
];
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame().trim()).toBe('Hello, world!');
unmount();
});
@@ -46,9 +47,10 @@ describe('<AnsiOutputText />', () => {
{ style: { inverse: true }, text: 'Inverse' },
])('correctly applies style $text', async ({ style, text }) => {
const data: AnsiOutput = [[createAnsiToken({ text, ...style })]];
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame().trim()).toBe(text);
unmount();
});
@@ -59,9 +61,10 @@ describe('<AnsiOutputText />', () => {
{ color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' },
])('correctly applies color $text', async ({ color, text }) => {
const data: AnsiOutput = [[createAnsiToken({ text, ...color })]];
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame().trim()).toBe(text);
unmount();
});
@@ -73,9 +76,10 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Third line' })],
[createAnsiToken({ text: '' })],
];
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
const lines = output.split('\n');
@@ -92,9 +96,10 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} availableTerminalHeight={2} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
@@ -110,9 +115,10 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={data} maxLines={2} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
@@ -129,7 +135,7 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 4' })],
];
// availableTerminalHeight=3, maxLines=2 => show 2 lines
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText
data={data}
availableTerminalHeight={3}
@@ -137,6 +143,7 @@ describe('<AnsiOutputText />', () => {
width={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
@@ -149,9 +156,10 @@ describe('<AnsiOutputText />', () => {
for (let i = 0; i < 1000; i++) {
largeData.push([createAnsiToken({ text: `Line ${i}` })]);
}
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<AnsiOutputText data={largeData} width={80} />,
);
await waitUntilReady();
// We are just checking that it renders something without crashing.
expect(lastFrame()).toBeDefined();
unmount();
@@ -10,7 +10,6 @@ import {
} from '../../test-utils/render.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
import crypto from 'node:crypto';
vi.mock('../utils/terminalSetup.js', () => ({
@@ -28,12 +27,13 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
@@ -50,12 +50,13 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('There are capacity issues');
expect(lastFrame()).toMatchSnapshot();
@@ -71,12 +72,13 @@ describe('<AppHeader />', () => {
},
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Banner');
expect(lastFrame()).toMatchSnapshot();
@@ -101,12 +103,13 @@ describe('<AppHeader />', () => {
},
});
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
@@ -126,12 +129,13 @@ describe('<AppHeader />', () => {
// and interfering with the expected persistentState.set call.
persistentStateMock.setData({ tipsShown: 10 });
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(persistentStateMock.set).toHaveBeenCalledWith(
'defaultBannerShownCount',
@@ -155,12 +159,13 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('First line\\nSecond line');
unmount();
@@ -178,12 +183,13 @@ describe('<AppHeader />', () => {
persistentStateMock.setData({ tipsShown: 5 });
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips');
expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6);
@@ -200,12 +206,13 @@ describe('<AppHeader />', () => {
persistentStateMock.setData({ tipsShown: 10 });
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Tips');
unmount();
@@ -227,6 +234,7 @@ describe('<AppHeader />', () => {
const session1 = await renderWithProviders(<AppHeader version="1.0.0" />, {
uiState,
});
await session1.waitUntilReady();
expect(session1.lastFrame()).toContain('Tips');
expect(persistentStateMock.get('tipsShown')).toBe(10);
@@ -237,31 +245,9 @@ describe('<AppHeader />', () => {
<AppHeader version="1.0.0" />,
{},
);
await session2.waitUntilReady();
expect(session2.lastFrame()).not.toContain('Tips');
session2.unmount();
});
it('should render the full logo when logged out', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: undefined,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState: {
terminalWidth: 120,
},
},
);
await waitUntilReady();
// Check for block characters from the logo
expect(lastFrame()).toContain('▗█▀▀▜▙');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
+51 -86
View File
@@ -19,9 +19,6 @@ import { CliSpinner } from './CliSpinner.js';
import { isAppleTerminal } from '@google/gemini-cli-core';
import { longAsciiLogoCompactText } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
interface AppHeaderProps {
version: string;
showDetails?: boolean;
@@ -44,18 +41,6 @@ const MAC_TERMINAL_ICON = `▝▜▄
`;
/**
* The horizontal padding (in columns) required for metadata (version, identity, etc.)
* when rendered alongside the ASCII logo.
*/
const LOGO_METADATA_PADDING = 20;
/**
* The terminal width below which we switch to a narrow/column layout to prevent
* UI elements from wrapping or overlapping.
*/
const NARROW_TERMINAL_BREAKPOINT = 60;
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
@@ -64,90 +49,70 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
const authType = config.getContentGeneratorConfig()?.authType;
const loggedOut = !authType;
const showHeader = !(
settings.merged.ui.hideBanner || config.getScreenReader()
);
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
let logoTextArt = '';
if (loggedOut) {
const widthOfLongLogo =
getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING;
if (terminalWidth >= widthOfLongLogo) {
logoTextArt = longAsciiLogoCompactText.trim();
}
}
// If the terminal is too narrow to fit the icon and metadata (especially long nightly versions)
// side-by-side, we switch to column mode to prevent wrapping.
const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT;
const renderLogo = () => (
<Box flexDirection="row">
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
{logoTextArt && (
<Box marginLeft={3}>
<Text color={theme.text.primary}>{logoTextArt}</Text>
</Box>
)}
</Box>
);
const renderMetadata = (isBelow = false) => (
<Box marginLeft={isBelow ? 0 : 2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
if (!showDetails) {
return (
<Box flexDirection="column">
{showHeader && (
<Box
flexDirection="row"
marginTop={1}
marginBottom={1}
paddingLeft={2}
>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
</Box>
</Box>
</Box>
)}
</Box>
{showDetails && (
<>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</>
)}
</Box>
);
const useColumnLayout = !!logoTextArt || isNarrow;
);
}
return (
<Box flexDirection="column">
{showHeader && (
<Box
flexDirection={useColumnLayout ? 'column' : 'row'}
marginTop={1}
marginBottom={1}
paddingLeft={1}
>
{renderLogo()}
{useColumnLayout ? (
<Box marginTop={1}>{renderMetadata(true)}</Box>
) : (
renderMetadata(false)
)}
<Box flexDirection="row" marginTop={1} marginBottom={1} paddingLeft={2}>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</Box>
</Box>
)}
@@ -11,50 +11,56 @@ import { ApprovalMode } from '@google/gemini-cli-core';
describe('ApprovalModeIndicator', () => {
it('renders correctly for AUTO_EDIT mode', async () => {
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for AUTO_EDIT mode with plan enabled', async () => {
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.AUTO_EDIT}
allowPlanMode={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for PLAN mode', async () => {
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for YOLO mode', async () => {
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode', async () => {
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode with plan enabled', async () => {
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.DEFAULT}
allowPlanMode={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
+8 -29
View File
@@ -16,14 +16,14 @@ export const shortAsciiLogo = `
`;
export const longAsciiLogo = `
`;
export const tinyAsciiLogo = `
@@ -36,24 +36,3 @@ export const tinyAsciiLogo = `
`;
export const shortAsciiLogoCompactText = `
`;
export const longAsciiLogoCompactText = `
`;
export const tinyAsciiLogoCompactText = `
`;
@@ -48,7 +48,7 @@ describe('AskUserDialog', () => {
];
it('renders question and options', async () => {
const { lastFrame } = await renderWithProviders(
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -58,6 +58,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -396,7 +397,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = await renderWithProviders(
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -406,11 +407,12 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('hides progress header for single question', async () => {
const { lastFrame } = await renderWithProviders(
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -420,11 +422,12 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('shows keyboard hints', async () => {
const { lastFrame } = await renderWithProviders(
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -434,6 +437,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -467,6 +471,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toContain('Which testing framework?');
writeKey(stdin, '\x1b[C'); // Right arrow
@@ -577,7 +582,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = await renderWithProviders(
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -587,6 +592,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -730,7 +736,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = await renderWithProviders(
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -740,6 +746,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -752,7 +759,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = await renderWithProviders(
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -762,6 +769,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -812,7 +820,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame } = await renderWithProviders(
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -822,6 +830,7 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -145,7 +145,7 @@ describe('<BackgroundShellDisplay />', () => {
it('renders the output of the active shell', async () => {
const width = 80;
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -158,6 +158,7 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -165,7 +166,7 @@ describe('<BackgroundShellDisplay />', () => {
it('renders tabs for multiple shells', async () => {
const width = 100;
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -178,6 +179,7 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -185,7 +187,7 @@ describe('<BackgroundShellDisplay />', () => {
it('highlights the focused state', async () => {
const width = 80;
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -198,6 +200,7 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -205,7 +208,7 @@ describe('<BackgroundShellDisplay />', () => {
it('resizes the PTY on mount and when dimensions change', async () => {
const width = 80;
const { rerender, unmount } = await render(
const { rerender, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -218,6 +221,7 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
@@ -237,6 +241,7 @@ describe('<BackgroundShellDisplay />', () => {
/>
</ScrollProvider>,
);
await waitUntilReady();
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
@@ -248,7 +253,7 @@ describe('<BackgroundShellDisplay />', () => {
it('renders the process list when isListOpenProp is true', async () => {
const width = 80;
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -261,6 +266,7 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -268,7 +274,7 @@ describe('<BackgroundShellDisplay />', () => {
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
const width = 80;
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -281,16 +287,19 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
await act(async () => {
simulateKey({ name: 'down' });
});
await waitUntilReady();
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
await act(async () => {
simulateKey({ name: 'l', ctrl: true });
});
await waitUntilReady();
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
@@ -299,7 +308,7 @@ describe('<BackgroundShellDisplay />', () => {
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
const width = 80;
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -312,6 +321,7 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
// Initial state: shell1 (active) is highlighted
@@ -319,11 +329,13 @@ describe('<BackgroundShellDisplay />', () => {
await act(async () => {
simulateKey({ name: 'down' });
});
await waitUntilReady();
// Press Ctrl+K
await act(async () => {
simulateKey({ name: 'k', ctrl: true });
});
await waitUntilReady();
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
unmount();
@@ -331,7 +343,7 @@ describe('<BackgroundShellDisplay />', () => {
it('kills the active process when Ctrl+K is pressed in output view', async () => {
const width = 80;
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -344,10 +356,12 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
await act(async () => {
simulateKey({ name: 'k', ctrl: true });
});
await waitUntilReady();
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
unmount();
@@ -356,7 +370,7 @@ describe('<BackgroundShellDisplay />', () => {
it('scrolls to active shell when list opens', async () => {
// shell2 is active
const width = 80;
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -369,6 +383,7 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -387,7 +402,7 @@ describe('<BackgroundShellDisplay />', () => {
mockShells.set(exitedShell.pid, exitedShell);
const width = 80;
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -400,6 +415,7 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -18,9 +18,10 @@ describe('<Checklist />', () => {
];
it('renders nothing when list is empty', async () => {
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<Checklist title="Test List" items={[]} isExpanded={true} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
@@ -29,14 +30,15 @@ describe('<Checklist />', () => {
{ status: 'completed', label: 'Task 1' },
{ status: 'cancelled', label: 'Task 2' },
];
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders summary view correctly (collapsed)', async () => {
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<Checklist
title="Test List"
items={items}
@@ -44,11 +46,12 @@ describe('<Checklist />', () => {
toggleHint="toggle me"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders expanded view correctly', async () => {
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<Checklist
title="Test List"
items={items}
@@ -56,6 +59,7 @@ describe('<Checklist />', () => {
toggleHint="toggle me"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -64,9 +68,10 @@ describe('<Checklist />', () => {
{ status: 'completed', label: 'Task 1' },
{ status: 'pending', label: 'Task 2' },
];
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -17,7 +17,8 @@ describe('<ChecklistItem />', () => {
{ status: 'cancelled', label: 'Skipped this' },
{ status: 'blocked', label: 'Blocked this' },
] as ChecklistItemData[])('renders %s item correctly', async (item) => {
const { lastFrame } = await render(<ChecklistItem item={item} />);
const { lastFrame, waitUntilReady } = render(<ChecklistItem item={item} />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -27,11 +28,12 @@ describe('<ChecklistItem />', () => {
label:
'This is a very long text that should be truncated because the wrap prop is set to truncate',
};
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<Box width={30}>
<ChecklistItem item={item} wrap="truncate" />
</Box>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -41,11 +43,12 @@ describe('<ChecklistItem />', () => {
label:
'This is a very long text that should wrap because the default behavior is wrapping',
};
const { lastFrame } = await render(
const { lastFrame, waitUntilReady } = render(
<Box width={30}>
<ChecklistItem item={item} />
</Box>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -17,7 +17,10 @@ describe('<CliSpinner />', () => {
it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => {
expect(debugState.debugNumAnimatedComponents).toBe(0);
const { unmount } = await renderWithProviders(<CliSpinner />);
const { waitUntilReady, unmount } = await renderWithProviders(
<CliSpinner />,
);
await waitUntilReady();
expect(debugState.debugNumAnimatedComponents).toBe(1);
unmount();
expect(debugState.debugNumAnimatedComponents).toBe(0);
@@ -25,9 +28,11 @@ describe('<CliSpinner />', () => {
it('should not render when showSpinner is false', async () => {
const settings = createMockSettings({ ui: { showSpinner: false } });
const { lastFrame, unmount } = await renderWithProviders(<CliSpinner />, {
settings,
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<CliSpinner />,
{ settings },
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -96,9 +96,10 @@ describe('ColorsDisplay', () => {
it('renders correctly', async () => {
const mockTheme = themeManager.getActiveTheme();
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<ColorsDisplay activeTheme={mockTheme} />,
);
await waitUntilReady();
const output = lastFrame();
// Check for title and description
@@ -251,7 +251,7 @@ const renderComposer = async (
config = createMockConfig(),
uiActions = createMockUIActions(),
) => {
const result = await render(
const result = render(
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
@@ -262,6 +262,7 @@ const renderComposer = async (
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
await result.waitUntilReady();
// Wait for shortcuts hint debounce if using fake timers
if (vi.isFakeTimers()) {
@@ -43,7 +43,10 @@ describe('ConfigInitDisplay', () => {
});
it('renders initial state', async () => {
const { lastFrame } = await renderWithProviders(<ConfigInitDisplay />);
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ConfigInitDisplay />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -33,13 +33,14 @@ describe('ConsentPrompt', () => {
it('renders a string prompt with MarkdownDisplay', async () => {
const prompt = 'Are you sure?';
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
{
@@ -54,13 +55,14 @@ describe('ConsentPrompt', () => {
it('renders a ReactNode prompt directly', async () => {
const prompt = <Text>Are you sure?</Text>;
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedMarkdownDisplay).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Are you sure?');
@@ -69,13 +71,14 @@ describe('ConsentPrompt', () => {
it('calls onConfirm with true when "Yes" is selected', async () => {
const prompt = 'Are you sure?';
const { waitUntilReady, unmount } = await render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
@@ -89,13 +92,14 @@ describe('ConsentPrompt', () => {
it('calls onConfirm with false when "No" is selected', async () => {
const prompt = 'Are you sure?';
const { waitUntilReady, unmount } = await render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
@@ -109,13 +113,14 @@ describe('ConsentPrompt', () => {
it('passes correct items to RadioButtonSelect', async () => {
const prompt = 'Are you sure?';
const { unmount } = await render(
const { waitUntilReady, unmount } = render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedRadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
@@ -10,9 +10,10 @@ import { describe, it, expect } from 'vitest';
describe('ConsoleSummaryDisplay', () => {
it('renders nothing when errorCount is 0', async () => {
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ConsoleSummaryDisplay errorCount={0} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -21,9 +22,10 @@ describe('ConsoleSummaryDisplay', () => {
[1, '1 error'],
[5, '5 errors'],
])('renders correct message for %i errors', async (count, expectedText) => {
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<ConsoleSummaryDisplay errorCount={count} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(expectedText);
expect(output).toContain('✖');
@@ -26,7 +26,8 @@ const renderWithWidth = async (
props: React.ComponentProps<typeof ContextSummaryDisplay>,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
const result = await render(<ContextSummaryDisplay {...props} />);
const result = render(<ContextSummaryDisplay {...props} />);
await result.waitUntilReady();
return result;
};
@@ -19,33 +19,35 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
describe('ContextUsageDisplay', () => {
it('renders correct percentage used', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<ContextUsageDisplay
promptTokenCount={5000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('50% used');
unmount();
});
it('renders correctly when usage is 0%', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<ContextUsageDisplay
promptTokenCount={0}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% used');
unmount();
});
it('renders abbreviated label when terminal width is small', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<ContextUsageDisplay
promptTokenCount={2000}
model="gemini-pro"
@@ -53,6 +55,7 @@ describe('ContextUsageDisplay', () => {
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('20%');
expect(output).not.toContain('context used');
@@ -60,26 +63,28 @@ describe('ContextUsageDisplay', () => {
});
it('renders 80% correctly', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<ContextUsageDisplay
promptTokenCount={8000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80% used');
unmount();
});
it('renders 100% when full', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<ContextUsageDisplay
promptTokenCount={10000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('100% used');
unmount();
@@ -22,7 +22,8 @@ describe('CopyModeWarning', () => {
mockUseUIState.mockReturnValue({
copyModeEnabled: false,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<CopyModeWarning />);
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -31,7 +32,8 @@ describe('CopyModeWarning', () => {
mockUseUIState.mockReturnValue({
copyModeEnabled: true,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<CopyModeWarning />);
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
await waitUntilReady();
expect(lastFrame()).toContain('In Copy Mode');
expect(lastFrame()).toContain('Use Page Up/Down to scroll');
expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit');
@@ -242,7 +242,8 @@ describe('DebugProfiler Component', () => {
showDebugProfiler: false,
constrainHeight: false,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<DebugProfiler />);
const { lastFrame, waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -256,7 +257,8 @@ describe('DebugProfiler Component', () => {
profiler.totalIdleFrames = 5;
profiler.totalFlickerFrames = 2;
const { lastFrame, unmount } = await render(<DebugProfiler />);
const { lastFrame, waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Renders: 10 (total)');
@@ -273,7 +275,8 @@ describe('DebugProfiler Component', () => {
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { waitUntilReady, unmount } = await render(<DebugProfiler />);
const { waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
await act(async () => {
coreEvents.emitModelChanged('new-model');
@@ -292,7 +295,8 @@ describe('DebugProfiler Component', () => {
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { waitUntilReady, unmount } = await render(<DebugProfiler />);
const { waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
await act(async () => {
appEvents.emit(AppEvent.SelectionWarning);
@@ -41,12 +41,13 @@ describe('DetailedMessagesDisplay', () => {
});
});
it('renders nothing when messages are empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -63,12 +64,13 @@ describe('DetailedMessagesDisplay', () => {
clearConsoleMessages: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
@@ -84,12 +86,13 @@ describe('DetailedMessagesDisplay', () => {
clearConsoleMessages: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
@@ -103,12 +106,13 @@ describe('DetailedMessagesDisplay', () => {
clearConsoleMessages: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
@@ -122,12 +126,13 @@ describe('DetailedMessagesDisplay', () => {
clearConsoleMessages: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
@@ -104,10 +104,11 @@ describe('DialogManager', () => {
};
it('renders nothing by default', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<DialogManager {...defaultProps} />,
{ uiState: baseUiState as Partial<UIState> as UIState },
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -196,7 +197,7 @@ describe('DialogManager', () => {
it.each(testCases)(
'renders %s when state is %o',
async (uiStateOverride, expectedComponent) => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<DialogManager {...defaultProps} />,
{
uiState: {
@@ -205,6 +206,7 @@ describe('DialogManager', () => {
} as Partial<UIState> as UIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain(expectedComponent);
unmount();
},
@@ -55,25 +55,27 @@ describe('EditorSettingsDialog', () => {
renderWithProviders(ui);
it('renders correctly', async () => {
const { lastFrame } = await renderWithProvider(
const { lastFrame, waitUntilReady } = await renderWithProvider(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={mockSettings}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('calls onSelect when an editor is selected', async () => {
const onSelect = vi.fn();
const { lastFrame } = await renderWithProvider(
const { lastFrame, waitUntilReady } = await renderWithProvider(
<EditorSettingsDialog
onSelect={onSelect}
settings={mockSettings}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('VS Code');
});
@@ -86,6 +88,7 @@ describe('EditorSettingsDialog', () => {
onExit={vi.fn()}
/>,
);
await waitUntilReady();
// Initial focus on editor
expect(lastFrame()).toContain('> Select Editor');
@@ -131,6 +134,7 @@ describe('EditorSettingsDialog', () => {
onExit={onExit}
/>,
);
await waitUntilReady();
await act(async () => {
stdin.write('\u001B'); // Escape
@@ -158,13 +162,14 @@ describe('EditorSettingsDialog', () => {
},
} as unknown as LoadedSettings;
const { lastFrame } = await renderWithProvider(
const { lastFrame, waitUntilReady } = await renderWithProvider(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={settingsWithOtherScope}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
const frame = lastFrame() || '';
if (!frame.includes('(Also modified')) {
@@ -30,7 +30,7 @@ describe('EmptyWalletDialog', () => {
describe('rendering', () => {
it('should match snapshot with fallback available', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
@@ -38,30 +38,33 @@ describe('EmptyWalletDialog', () => {
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should match snapshot without fallback', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should display the model name and usage limit message', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('gemini-2.5-pro');
@@ -70,12 +73,13 @@ describe('EmptyWalletDialog', () => {
});
it('should display purchase prompt and credits update notice', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('purchase more AI Credits');
@@ -86,13 +90,14 @@ describe('EmptyWalletDialog', () => {
});
it('should display reset time when provided', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
resetTime="3:45 PM"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('3:45 PM');
@@ -101,12 +106,13 @@ describe('EmptyWalletDialog', () => {
});
it('should not display reset time when not provided', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).not.toContain('Access resets at');
@@ -114,12 +120,13 @@ describe('EmptyWalletDialog', () => {
});
it('should display slash command hints', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('/stats');
@@ -132,13 +139,14 @@ describe('EmptyWalletDialog', () => {
describe('onChoice handling', () => {
it('should call onGetCredits and onChoice when get_credits is selected', async () => {
// get_credits is the first item, so just press Enter
const { unmount, stdin } = await renderWithProviders(
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
onGetCredits={mockOnGetCredits}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
@@ -150,12 +158,13 @@ describe('EmptyWalletDialog', () => {
});
it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => {
const { unmount, stdin } = await renderWithProviders(
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
@@ -168,13 +177,14 @@ describe('EmptyWalletDialog', () => {
it('should call onChoice with use_fallback when selected', async () => {
// With fallback: items are [get_credits, use_fallback, stop]
// use_fallback is the second item: Down + Enter
const { unmount, stdin } = await renderWithProviders(
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
@@ -188,12 +198,13 @@ describe('EmptyWalletDialog', () => {
it('should call onChoice with stop when selected', async () => {
// Without fallback: items are [get_credits, stop]
// stop is the second item: Down + Enter
const { unmount, stdin } = await renderWithProviders(
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
@@ -440,38 +440,36 @@ Implement a comprehensive authentication system with multiple providers.
return <>{children}</>;
};
const { stdin, lastFrame } = await act(async () =>
renderWithProviders(
<BubbleListener>
<ExitPlanModeDialog
planPath={mockPlanFullPath}
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
/>
</BubbleListener>,
{
config: {
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
storage: {
getPlansDir: () => mockPlansDir,
},
getFileSystemService: (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
const { stdin, lastFrame } = await renderWithProviders(
<BubbleListener>
<ExitPlanModeDialog
planPath={mockPlanFullPath}
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
/>
</BubbleListener>,
{
config: {
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
storage: {
getPlansDir: () => mockPlansDir,
},
getFileSystemService: (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
},
),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
}),
},
);
await act(async () => {
@@ -24,7 +24,8 @@ describe('ExitWarning', () => {
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<ExitWarning />);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -35,7 +36,8 @@ describe('ExitWarning', () => {
ctrlCPressedOnce: true,
ctrlDPressedOnce: false,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<ExitWarning />);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
unmount();
});
@@ -46,7 +48,8 @@ describe('ExitWarning', () => {
ctrlCPressedOnce: false,
ctrlDPressedOnce: true,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<ExitWarning />);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
unmount();
});
@@ -57,7 +60,8 @@ describe('ExitWarning', () => {
ctrlCPressedOnce: true,
ctrlDPressedOnce: true,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<ExitWarning />);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -48,9 +48,10 @@ describe('FolderTrustDialog', () => {
});
it('should render the dialog with title and description', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Do you trust the files in this folder?');
expect(lastFrame()).toContain(
@@ -71,7 +72,7 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -84,6 +85,7 @@ describe('FolderTrustDialog', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('This folder contains:');
expect(lastFrame()).toContain('hidden');
unmount();
@@ -101,7 +103,7 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -114,6 +116,7 @@ describe('FolderTrustDialog', () => {
},
);
await waitUntilReady();
// With maxHeight=4, the intro text (4 lines) will take most of the space.
// The discovery results will likely be hidden.
expect(lastFrame()).toContain('hidden');
@@ -132,7 +135,7 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -145,6 +148,7 @@ describe('FolderTrustDialog', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('hidden');
unmount();
});
@@ -178,7 +182,9 @@ describe('FolderTrustDialog', () => {
// Initial state: truncated
await waitFor(() => {
expect(lastFrame()).toContain('Do you trust the files in this folder?');
expect(lastFrame()).toContain('Press Ctrl+O');
// In standard terminal mode, the expansion hint is handled globally by ToastDisplay
// via AppContainer, so it should not be present in the dialog's local frame.
expect(lastFrame()).not.toContain('Press Ctrl+O');
expect(lastFrame()).toContain('hidden');
});
@@ -215,6 +221,7 @@ describe('FolderTrustDialog', () => {
await renderWithProviders(
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
);
await waitUntilReady();
await act(async () => {
stdin.write('\u001b[27u'); // Press kitty escape key
@@ -239,9 +246,10 @@ describe('FolderTrustDialog', () => {
});
it('should display restart message when isRestarting is true', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Gemini CLI is restarting');
unmount();
@@ -252,9 +260,10 @@ describe('FolderTrustDialog', () => {
const relaunchApp = vi
.spyOn(processUtils, 'relaunchApp')
.mockResolvedValue(undefined);
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await waitUntilReady();
await vi.advanceTimersByTimeAsync(250);
expect(relaunchApp).toHaveBeenCalled();
unmount();
@@ -266,9 +275,10 @@ describe('FolderTrustDialog', () => {
const relaunchApp = vi
.spyOn(processUtils, 'relaunchApp')
.mockResolvedValue(undefined);
const { unmount } = await renderWithProviders(
const { waitUntilReady, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await waitUntilReady();
// Unmount immediately (before 250ms)
unmount();
@@ -282,6 +292,7 @@ describe('FolderTrustDialog', () => {
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
);
await waitUntilReady();
await act(async () => {
stdin.write('r');
@@ -297,27 +308,30 @@ describe('FolderTrustDialog', () => {
describe('directory display', () => {
it('should correctly display the folder name for a nested directory', async () => {
mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Trust folder (project)');
unmount();
});
it('should correctly display the parent folder name for a nested directory', async () => {
mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Trust parent folder (user)');
unmount();
});
it('should correctly display an empty parent folder name for a directory directly under root', async () => {
mockedCwd.mockReturnValue('/project');
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Trust parent folder ()');
unmount();
});
@@ -334,7 +348,7 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -342,6 +356,7 @@ describe('FolderTrustDialog', () => {
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toContain('This folder contains:');
expect(lastFrame()).toContain('• Commands (2):');
expect(lastFrame()).toContain('- cmd1');
@@ -371,13 +386,14 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: ['Dangerous setting detected!'],
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Security Warnings:');
expect(lastFrame()).toContain('Dangerous setting detected!');
unmount();
@@ -394,13 +410,14 @@ describe('FolderTrustDialog', () => {
discoveryErrors: ['Failed to load custom commands'],
securityWarnings: [],
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Discovery Errors:');
expect(lastFrame()).toContain('Failed to load custom commands');
unmount();
@@ -417,7 +434,7 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -430,6 +447,7 @@ describe('FolderTrustDialog', () => {
},
);
await waitUntilReady();
// In alternate buffer + expanded, the title should be visible (StickyHeader)
expect(lastFrame()).toContain('Do you trust the files in this folder?');
// And it should NOT use MaxSizedBox truncation
@@ -452,7 +470,7 @@ describe('FolderTrustDialog', () => {
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
};
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -460,6 +478,7 @@ describe('FolderTrustDialog', () => {
{ width: 100, uiState: { terminalHeight: 40 } },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('cmd-with-ansi');
+422 -302
View File
@@ -138,25 +138,33 @@ describe('<Footer />', () => {
});
it('renders the component', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
},
});
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
describe('path display', () => {
it('should display a shortened path on a narrow terminal', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
// Should contain some part of the path, likely shortened
@@ -165,11 +173,15 @@ describe('<Footer />', () => {
});
it('should use wide layout at 80 columns', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 80,
uiState: { sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 80,
uiState: { sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain(path.join('make', 'it'));
@@ -177,24 +189,28 @@ describe('<Footer />', () => {
});
it('should not truncate high-priority items on narrow terminals (regression)', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 60,
uiState: {
sessionStats: mockSessionStats,
},
settings: createMockSettings({
general: {
vimMode: true,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 60,
uiState: {
sessionStats: mockSessionStats,
},
ui: {
footer: {
showLabels: true,
items: ['workspace', 'model-name'],
settings: createMockSettings({
general: {
vimMode: true,
},
},
}),
});
ui: {
footer: {
showLabels: true,
items: ['workspace', 'model-name'],
},
},
}),
},
);
await waitUntilReady();
const output = lastFrame();
// [INSERT] is high priority and should be fully visible
// (Note: VimModeProvider defaults to 'INSERT' mode when enabled)
@@ -206,140 +222,168 @@ describe('<Footer />', () => {
});
it('displays the branch name when provided', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
},
});
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.branchName);
unmount();
});
it('does not display the branch name when not provided', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { branchName: undefined, sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { branchName: undefined, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Branch');
unmount();
});
it('displays the model name and context percentage', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
currentModel: defaultProps.model,
sessionStats: {
...mockSessionStats,
lastPromptTokenCount: 1000,
},
},
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
currentModel: defaultProps.model,
sessionStats: {
...mockSessionStats,
lastPromptTokenCount: 1000,
},
},
}),
});
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% used/);
unmount();
});
it('displays the usage indicator when usage is low', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
});
);
await waitUntilReady();
expect(lastFrame()).toContain('85%');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
it('hides the usage indicator when usage is not near limit', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
});
);
await waitUntilReady();
expect(normalizeFrame(lastFrame())).not.toContain('used');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
it('displays "Limit reached" message when remaining is 0', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
});
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain('limit reached');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 99,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 99,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+%/);
expect(lastFrame()).not.toContain('context used');
@@ -348,25 +392,33 @@ describe('<Footer />', () => {
describe('sandbox and trust info', () => {
it('should display untrusted when isTrustedFolder is false', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).toContain('untrusted');
unmount();
});
it('should display custom sandbox info when SANDBOX env is set', async () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
isTrustedFolder: undefined,
sessionStats: mockSessionStats,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
isTrustedFolder: undefined,
sessionStats: mockSessionStats,
},
},
});
);
await waitUntilReady();
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
unmount();
@@ -375,11 +427,15 @@ describe('<Footer />', () => {
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
unmount();
@@ -388,11 +444,15 @@ describe('<Footer />', () => {
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', async () => {
// Clear any SANDBOX env var that might be set.
vi.stubEnv('SANDBOX', '');
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).toContain('no sandbox');
vi.unstubAllEnvs();
unmount();
@@ -400,11 +460,15 @@ describe('<Footer />', () => {
it('should prioritize untrusted message over sandbox info', async () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
expect(lastFrame()).toContain('untrusted');
expect(lastFrame()).not.toMatch(/test-sandbox/s);
vi.unstubAllEnvs();
@@ -414,18 +478,22 @@ describe('<Footer />', () => {
describe('footer configuration filtering (golden snapshots)', () => {
it('renders complete footer with all sections visible (baseline)', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'complete-footer-wide',
);
@@ -455,39 +523,47 @@ describe('<Footer />', () => {
});
it('renders footer with only model info hidden (partial filtering)', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
unmount();
});
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'footer-only-sandbox',
);
@@ -495,52 +571,64 @@ describe('<Footer />', () => {
});
it('hides the context percentage when hideContextPercentage is true', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: true,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: true,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% used/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% used/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
},
}),
});
}),
},
);
await waitUntilReady();
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'complete-footer-narrow',
);
@@ -626,48 +714,60 @@ describe('<Footer />', () => {
});
it('hides error summary in low verbosity mode out of dev mode', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
},
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
});
);
await waitUntilReady();
expect(lastFrame()).not.toContain('F12 for details');
unmount();
});
it('shows error summary in low verbosity mode in dev mode', async () => {
mocks.isDevelopment = true;
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
},
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
});
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
});
it('shows error summary in full verbosity mode', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
});
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
@@ -676,21 +776,25 @@ describe('<Footer />', () => {
describe('Footer Custom Items', () => {
it('renders items in the specified order', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
},
settings: createMockSettings({
ui: {
footer: {
items: ['model-name', 'workspace'],
},
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
},
}),
});
settings: createMockSettings({
ui: {
footer: {
items: ['model-name', 'workspace'],
},
},
}),
},
);
await waitUntilReady();
const output = lastFrame();
const modelIdx = output.indexOf('/model');
@@ -700,24 +804,28 @@ describe('<Footer />', () => {
});
it('renders multiple items with proper alignment', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: 'main',
},
settings: createMockSettings({
vimMode: {
vimMode: true,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: 'main',
},
ui: {
footer: {
items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
settings: createMockSettings({
vimMode: {
vimMode: true,
},
},
}),
});
ui: {
footer: {
items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
},
},
}),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
@@ -754,21 +862,25 @@ describe('<Footer />', () => {
});
it('does not render items that are conditionally hidden', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: undefined, // No branch
},
settings: createMockSettings({
ui: {
footer: {
items: ['workspace', 'git-branch', 'model-name'],
},
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: undefined, // No branch
},
}),
});
settings: createMockSettings({
ui: {
footer: {
items: ['workspace', 'git-branch', 'model-name'],
},
},
}),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
@@ -781,14 +893,18 @@ describe('<Footer />', () => {
describe('fallback mode display', () => {
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
},
},
});
);
await waitUntilReady();
// Footer should show the effective model (Flash), not the config model (Pro)
expect(lastFrame()).toContain('gemini-2.5-flash');
@@ -797,14 +913,18 @@ describe('<Footer />', () => {
});
it('should display Pro model when NOT in fallback mode', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
},
},
});
);
await waitUntilReady();
expect(lastFrame()).toContain('gemini-2.5-pro');
unmount();
@@ -30,17 +30,19 @@ describe('<FooterConfigDialog />', () => {
{ settings },
);
await renderResult.waitUntilReady();
expect(renderResult.lastFrame()).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
});
it('toggles an item when enter is pressed', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = await renderWithProviders(
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
act(() => {
stdin.write('\r'); // Enter to toggle
});
@@ -60,11 +62,12 @@ describe('<FooterConfigDialog />', () => {
it('reorders items with arrow keys', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = await renderWithProviders(
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Initial order: workspace, git-branch, ...
const output = lastFrame();
const cwdIdx = output.indexOf('] workspace');
@@ -90,11 +93,12 @@ describe('<FooterConfigDialog />', () => {
it('closes on Esc', async () => {
const settings = createMockSettings();
const { stdin } = await renderWithProviders(
const { stdin, waitUntilReady } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
act(() => {
stdin.write('\x1b'); // Esc
});
@@ -111,8 +115,9 @@ describe('<FooterConfigDialog />', () => {
{ settings },
);
const { lastFrame, stdin } = renderResult;
const { lastFrame, stdin, waitUntilReady } = renderResult;
await waitUntilReady();
expect(lastFrame()).toContain('~/project/path');
// Move focus down to 'code-changes' (which has colored elements)
@@ -143,11 +148,13 @@ describe('<FooterConfigDialog />', () => {
it('shows an empty preview when all items are deselected', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = await renderWithProviders(
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Default items are the first 5. We toggle them off.
for (let i = 0; i < 5; i++) {
act(() => {
@@ -171,10 +178,11 @@ describe('<FooterConfigDialog />', () => {
it('moves item correctly after trying to move up at the top', async () => {
const settings = createMockSettings();
const { lastFrame, stdin } = await renderWithProviders(
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Default initial items in mock settings are 'git-branch', 'workspace', ...
await waitFor(() => {
@@ -214,7 +222,8 @@ describe('<FooterConfigDialog />', () => {
{ settings },
);
const { lastFrame, stdin } = renderResult;
const { lastFrame, stdin, waitUntilReady } = renderResult;
await waitUntilReady();
// By default labels are on
expect(lastFrame()).toContain('workspace (/directory)');
@@ -41,7 +41,10 @@ describe('GeminiRespondingSpinner', () => {
it('renders spinner when responding', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
expect(lastFrame()).toContain('GeminiSpinner');
unmount();
});
@@ -49,23 +52,30 @@ describe('GeminiRespondingSpinner', () => {
it('renders screen reader text when responding and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
unmount();
});
it('renders nothing when not responding and no non-responding display', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders non-responding display when provided', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Waiting...');
unmount();
});
@@ -73,9 +83,10 @@ describe('GeminiRespondingSpinner', () => {
it('renders screen reader loading text when non-responding display provided and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, unmount } = await render(
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
await waitUntilReady();
expect(lastFrame()).toContain(SCREEN_READER_LOADING);
unmount();
});
@@ -10,7 +10,7 @@ import * as SessionContext from '../contexts/SessionContext.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { Banner } from './Banner.js';
import { Footer } from './Footer.js';
import { AppHeader } from './AppHeader.js';
import { Header } from './Header.js';
import { ModelDialog } from './ModelDialog.js';
import { StatsDisplay } from './StatsDisplay.js';
@@ -71,47 +71,54 @@ useSessionStatsMock.mockReturnValue({
});
describe('Gradient Crash Regression Tests', () => {
it('<AppHeader /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
it('<Header /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Header version="1.0.0" nightly={false} />,
{
width: 120,
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
it('<ModelDialog /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<ModelDialog onClose={async () => {}} />,
{
width: 120,
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
it('<Banner /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Banner bannerText="Test Banner" isWarning={false} width={80} />,
{
width: 120,
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
it('<Footer /> should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
width: 120,
uiState: {
nightly: true, // Enable nightly to trigger Gradient usage logic
sessionStats: mockSessionStats,
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
nightly: true, // Enable nightly to trigger Gradient usage logic
sessionStats: mockSessionStats,
},
},
});
);
await waitUntilReady();
// If it crashes, this line won't be reached or lastFrame() will throw
expect(lastFrame()).toBeDefined();
// It should fall back to rendering text without gradient
@@ -120,7 +127,7 @@ describe('Gradient Crash Regression Tests', () => {
});
it('<StatsDisplay /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<StatsDisplay duration="1s" title="My Stats" />,
{
width: 120,
@@ -129,6 +136,7 @@ describe('Gradient Crash Regression Tests', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
// Ensure title is rendered
expect(lastFrame()).toContain('My Stats');

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