mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ea49d486b | |||
| 4d4fc80f25 | |||
| dfa56c37b4 | |||
| 447a854ad9 | |||
| b58d79c517 | |||
| daf3691841 | |||
| 517961b2eb | |||
| ec0161ad37 | |||
| cdf077da56 | |||
| 99e5164c82 | |||
| d0ebc81c28 | |||
| c67817f1a9 | |||
| c7d44e339b | |||
| 6055c47079 | |||
| 4c533b1249 | |||
| 4a3d9414ef | |||
| 0df9498674 | |||
| 8f391585ab | |||
| e7b6326cfa | |||
| 1a70fdd364 | |||
| b316fcc44d | |||
| d1dc4902fd | |||
| d3766875f8 | |||
| 28935d1e6b | |||
| 244a608186 | |||
| fc03891a11 | |||
| 974d29128f | |||
| 3382e0413e | |||
| 992c04e768 | |||
| fbb17ebf58 | |||
| e8fe43bd69 | |||
| 11ec4ac2f8 | |||
| 8eb419a47a | |||
| 4e80f01fda | |||
| 6c78eb7a39 | |||
| 86a3a913b5 | |||
| 05e4ea80ee | |||
| cca595971d | |||
| 26b9af1cdc | |||
| b459e1a108 | |||
| 62cb14fa52 | |||
| 7a65c1e91d | |||
| 5a3c7154df | |||
| b9c87c14a2 | |||
| 52250c162d | |||
| b52641de0d | |||
| 4249de5e23 | |||
| 536bbdf73f | |||
| 201efbd07f | |||
| 8ae078b4d7 | |||
| d84b173330 | |||
| 33d654e7d9 | |||
| 6eb100cc41 | |||
| dca1ef09cb | |||
| 616c944350 | |||
| c0f919310e | |||
| 92a09c8d8d | |||
| ecae62e25a | |||
| abb4583554 | |||
| fc7a269f0a | |||
| 9c85ef1fda | |||
| bd60ba9e68 | |||
| 2e466bd4cb | |||
| 55778583b9 | |||
| 661bd39362 | |||
| 04eb2ee9a3 | |||
| c6f6fb00c9 | |||
| 02eff4efb3 | |||
| db9eef50ef |
@@ -7,5 +7,15 @@
|
|||||||
},
|
},
|
||||||
"general": {
|
"general": {
|
||||||
"devtools": true
|
"devtools": true
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"overrides": {
|
||||||
|
"browser_agent": { "enabled": true }
|
||||||
|
},
|
||||||
|
"browser": {
|
||||||
|
"headless": true,
|
||||||
|
"sessionMode": "isolated",
|
||||||
|
"allowedDomains": ["*.com"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: 'Evals: PR Guidance'
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'packages/core/src/**/*.ts'
|
||||||
|
- '!**/*.test.ts'
|
||||||
|
- '!**/*.test.tsx'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: 'write'
|
||||||
|
contents: 'read'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
provide-guidance:
|
||||||
|
name: 'Model Steering Guidance'
|
||||||
|
runs-on: 'ubuntu-latest'
|
||||||
|
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||||
|
steps:
|
||||||
|
- name: 'Checkout'
|
||||||
|
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 'Set up Node.js'
|
||||||
|
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: 'Detect Steering Changes'
|
||||||
|
id: 'detect'
|
||||||
|
run: |
|
||||||
|
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
|
||||||
|
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: 'Analyze PR Content'
|
||||||
|
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
|
||||||
|
id: 'analysis'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
|
run: |
|
||||||
|
# Check for behavioral eval changes
|
||||||
|
EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true)
|
||||||
|
if [ -z "$EVAL_CHANGES" ]; then
|
||||||
|
echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if user is a maintainer (has write/admin access)
|
||||||
|
USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission')
|
||||||
|
if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then
|
||||||
|
echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 'Post Guidance Comment'
|
||||||
|
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
|
||||||
|
uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
|
||||||
|
with:
|
||||||
|
comment-tag: 'eval-guidance-bot'
|
||||||
|
message: |
|
||||||
|
### 🧠 Model Steering Guidance
|
||||||
|
|
||||||
|
This PR modifies files that affect the model's behavior (prompts, tools, or instructions).
|
||||||
|
|
||||||
|
${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- ⚠️ **Consider adding Evals:** No behavioral evaluations (`evals/*.eval.ts`) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.' || '' }}
|
||||||
|
${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- 🚀 **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }}
|
||||||
|
|
||||||
|
---
|
||||||
|
*This is an automated guidance message triggered by steering logic signatures.*
|
||||||
@@ -61,6 +61,7 @@ jobs:
|
|||||||
GEMINI_MODEL: '${{ matrix.model }}'
|
GEMINI_MODEL: '${{ matrix.model }}'
|
||||||
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
|
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
|
||||||
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
|
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
|
||||||
|
VITEST_RETRY: 0
|
||||||
run: |
|
run: |
|
||||||
CMD="npm run test:all_evals"
|
CMD="npm run test:all_evals"
|
||||||
PATTERN="${TEST_NAME_PATTERN}"
|
PATTERN="${TEST_NAME_PATTERN}"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ These commands are available within the interactive REPL.
|
|||||||
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
|
| `--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` | `-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 |
|
| `--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 |
|
| `--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` |
|
| `--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. |
|
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Git Worktrees (experimental)
|
||||||
|
|
||||||
|
When working on multiple tasks at once, you can use Git worktrees to give each
|
||||||
|
Gemini session its own copy of the codebase. Git worktrees create separate
|
||||||
|
working directories that each have their own files and branch while sharing the
|
||||||
|
same repository history. This prevents changes in one session from colliding
|
||||||
|
with another.
|
||||||
|
|
||||||
|
Learn more about [session management](./session-management.md).
|
||||||
|
|
||||||
|
<!-- prettier-ignore -->
|
||||||
|
> [!NOTE]
|
||||||
|
> This is an experimental feature currently under active development. Your
|
||||||
|
> feedback is invaluable as we refine this feature. If you have ideas,
|
||||||
|
> suggestions, or encounter issues:
|
||||||
|
>
|
||||||
|
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub.
|
||||||
|
> - Use the **/bug** command within Gemini CLI to file an issue.
|
||||||
|
|
||||||
|
Learn more in the official Git worktree
|
||||||
|
[documentation](https://git-scm.com/docs/git-worktree).
|
||||||
|
|
||||||
|
## How to enable Git worktrees
|
||||||
|
|
||||||
|
Git worktrees are an experimental feature. You must enable them in your settings
|
||||||
|
using the `/settings` command or by manually editing your `settings.json` file.
|
||||||
|
|
||||||
|
1. Use the `/settings` command.
|
||||||
|
2. Search for and set **Enable Git Worktrees** to `true`.
|
||||||
|
|
||||||
|
Alternatively, add the following to your `settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experimental": {
|
||||||
|
"worktrees": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to use Git worktrees
|
||||||
|
|
||||||
|
Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini
|
||||||
|
CLI in it.
|
||||||
|
|
||||||
|
- **Start with a specific name:** The value you pass becomes both the directory
|
||||||
|
name (within `.gemini/worktrees/`) and the branch name.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini --worktree feature-search
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Start with a random name:** If you omit the name, Gemini generates a random
|
||||||
|
one automatically (for example, `worktree-a1b2c3d4`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gemini --worktree
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- prettier-ignore -->
|
||||||
|
> [!NOTE]
|
||||||
|
> Remember to initialize your development environment in each new
|
||||||
|
> worktree according to your project's setup. Depending on your stack, this
|
||||||
|
> might include running dependency installation (`npm install`, `yarn`), setting
|
||||||
|
> up virtual environments, or following your project's standard build process.
|
||||||
|
|
||||||
|
## How to exit a Git worktree session
|
||||||
|
|
||||||
|
When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the
|
||||||
|
worktree intact so your work is not lost. This includes your uncommitted changes
|
||||||
|
(modified files, staged changes, or untracked files) and any new commits you
|
||||||
|
have made.
|
||||||
|
|
||||||
|
Gemini prioritizes a fast and safe exit: it **does not automatically delete**
|
||||||
|
your worktree or branch. You are responsible for cleaning up your worktrees
|
||||||
|
manually once you are finished with them.
|
||||||
|
|
||||||
|
When you exit, Gemini displays instructions on how to resume your work or how to
|
||||||
|
manually remove the worktree if you no longer need it.
|
||||||
|
|
||||||
|
## Resuming work in a Git worktree
|
||||||
|
|
||||||
|
To resume a session in a worktree, navigate to the worktree directory and start
|
||||||
|
Gemini CLI with the `--resume` flag and the session ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd .gemini/worktrees/feature-search
|
||||||
|
gemini --resume <session_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Managing Git worktrees manually
|
||||||
|
|
||||||
|
For more control over worktree location and branch configuration, or to clean up
|
||||||
|
a preserved worktree, you can use Git directly:
|
||||||
|
|
||||||
|
- **Clean up a preserved Git worktree:**
|
||||||
|
```bash
|
||||||
|
git worktree remove .gemini/worktrees/feature-search --force
|
||||||
|
git branch -D worktree-feature-search
|
||||||
|
```
|
||||||
|
- **Create a Git worktree manually:**
|
||||||
|
```bash
|
||||||
|
git worktree add ../project-feature-search -b feature-search
|
||||||
|
cd ../project-feature-search && gemini
|
||||||
|
```
|
||||||
|
|
||||||
|
[Open an issue]: https://github.com/google-gemini/gemini-cli/issues
|
||||||
@@ -96,6 +96,12 @@ Compatibility aliases:
|
|||||||
- `/chat ...` works for the same commands.
|
- `/chat ...` works for the same commands.
|
||||||
- `/resume checkpoints ...` also remains supported during migration.
|
- `/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
|
## Managing sessions
|
||||||
|
|
||||||
You can list and delete sessions to keep your history organized and manage disk
|
You can list and delete sessions to keep your history organized and manage disk
|
||||||
|
|||||||
@@ -101,6 +101,13 @@ they appear in the UI.
|
|||||||
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
|
| 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` |
|
| 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
|
### Context
|
||||||
|
|
||||||
| UI Label | Setting | Description | Default |
|
| UI Label | Setting | Description | Default |
|
||||||
@@ -151,6 +158,7 @@ they appear in the UI.
|
|||||||
| UI Label | Setting | Description | Default |
|
| UI Label | Setting | Description | Default |
|
||||||
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
| 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 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` |
|
| 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` |
|
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ Emitted at startup with the CLI configuration.
|
|||||||
- `extension_ids` (string)
|
- `extension_ids` (string)
|
||||||
- `extensions_count` (int)
|
- `extensions_count` (int)
|
||||||
- `auth_type` (string)
|
- `auth_type` (string)
|
||||||
|
- `worktree_active` (boolean)
|
||||||
- `github_workflow_name` (string, optional)
|
- `github_workflow_name` (string, optional)
|
||||||
- `github_repository_hash` (string, optional)
|
- `github_repository_hash` (string, optional)
|
||||||
- `github_event_name` (string, optional)
|
- `github_event_name` (string, optional)
|
||||||
@@ -903,6 +904,20 @@ Logs keychain availability checks.
|
|||||||
|
|
||||||
- `available` (boolean)
|
- `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>
|
</details>
|
||||||
|
|
||||||
### Metrics
|
### Metrics
|
||||||
@@ -919,6 +934,20 @@ Gemini CLI exports several custom metrics.
|
|||||||
|
|
||||||
Incremented once per CLI startup.
|
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
|
##### Tools
|
||||||
|
|
||||||
##### `gemini_cli.tool.call.count`
|
##### `gemini_cli.tool.call.count`
|
||||||
|
|||||||
@@ -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.
|
GitHub, you must have `git` installed on your machine.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
|
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent] [--skip-settings]
|
||||||
```
|
```
|
||||||
|
|
||||||
- `<source>`: The GitHub URL or local path of the extension.
|
- `<source>`: The GitHub URL or local path of the extension.
|
||||||
@@ -31,6 +31,7 @@ gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release]
|
|||||||
- `--auto-update`: Enable automatic updates for this extension.
|
- `--auto-update`: Enable automatic updates for this extension.
|
||||||
- `--pre-release`: Enable installation of pre-release versions.
|
- `--pre-release`: Enable installation of pre-release versions.
|
||||||
- `--consent`: Acknowledge security risks and skip the confirmation prompt.
|
- `--consent`: Acknowledge security risks and skip the confirmation prompt.
|
||||||
|
- `--skip-settings`: Skip the configuration on install process.
|
||||||
|
|
||||||
### Uninstall an extension
|
### Uninstall an extension
|
||||||
|
|
||||||
|
|||||||
@@ -470,5 +470,5 @@ console.error('Consolidating memories for session end...');
|
|||||||
|
|
||||||
While project-level hooks are great for specific repositories, you can share
|
While project-level hooks are great for specific repositories, you can share
|
||||||
your hooks across multiple projects by packaging them as a
|
your hooks across multiple projects by packaging them as a
|
||||||
[Gemini CLI extension](https://www.google.com/search?q=../extensions/index.md).
|
[Gemini CLI extension](../extensions/index.md). This provides version control,
|
||||||
This provides version control, easy distribution, and centralized management.
|
easy distribution, and centralized management.
|
||||||
|
|||||||
@@ -1210,6 +1210,17 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **Description:** Disable user input on browser window during automation.
|
- **Description:** Disable user input on browser window during automation.
|
||||||
- **Default:** `true`
|
- **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`
|
||||||
|
|
||||||
- **`context.fileName`** (string | string[]):
|
- **`context.fileName`** (string | string[]):
|
||||||
@@ -1527,6 +1538,11 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
- **Requires restart:** Yes
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
|
- **`experimental.worktrees`** (boolean):
|
||||||
|
- **Description:** Enable automated Git worktree management for parallel work.
|
||||||
|
- **Default:** `false`
|
||||||
|
- **Requires restart:** Yes
|
||||||
|
|
||||||
- **`experimental.extensionManagement`** (boolean):
|
- **`experimental.extensionManagement`** (boolean):
|
||||||
- **Description:** Enable extension management features.
|
- **Description:** Enable extension management features.
|
||||||
- **Default:** `true`
|
- **Default:** `true`
|
||||||
|
|||||||
@@ -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.
|
# A unique name for the tool, or an array of names.
|
||||||
toolName = "run_shell_command"
|
toolName = "run_shell_command"
|
||||||
|
|
||||||
# (Optional) The name of a subagent. If provided, the rule only applies to tool calls
|
# (Optional) The name of a subagent. If provided, the rule only applies to tool
|
||||||
# made by this specific subagent.
|
# calls made by this specific subagent.
|
||||||
subagent = "generalist"
|
subagent = "generalist"
|
||||||
|
|
||||||
# (Optional) The name of an MCP server. Can be combined with toolName
|
# (Optional) The name of an MCP server. Can be combined with toolName
|
||||||
@@ -278,14 +278,17 @@ toolAnnotations = { readOnlyHint = true }
|
|||||||
argsPattern = '"command":"(git|npm)'
|
argsPattern = '"command":"(git|npm)'
|
||||||
|
|
||||||
# (Optional) A string or array of strings that a shell command must start with.
|
# (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"
|
commandPrefix = "git"
|
||||||
|
|
||||||
# (Optional) A regex to match against the entire shell command.
|
# (Optional) A regex to match against the entire shell command.
|
||||||
# This is also syntactic sugar for `toolName = "run_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>"}`).
|
# Note: This pattern is tested against the JSON representation of the arguments
|
||||||
# Because it prepends `"command":"`, it effectively matches from the start of the command.
|
# (e.g., `{"command":"<your_command>"}`). Because it prepends `"command":"`,
|
||||||
# Anchors like `^` or `$` apply to the full JSON string, so `^` should usually be avoided here.
|
# 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.
|
# You cannot use commandPrefix and commandRegex in the same rule.
|
||||||
commandRegex = "git (commit|push)"
|
commandRegex = "git (commit|push)"
|
||||||
|
|
||||||
@@ -295,14 +298,16 @@ decision = "ask_user"
|
|||||||
# The priority of the rule, from 0 to 999.
|
# The priority of the rule, from 0 to 999.
|
||||||
priority = 10
|
priority = 10
|
||||||
|
|
||||||
# (Optional) A custom message to display when a tool call is denied by this rule.
|
# (Optional) A custom message to display when a tool call is denied by this
|
||||||
# This message is returned to the model and user, useful for explaining *why* it was denied.
|
# rule. This message is returned to the model and user,
|
||||||
|
# useful for explaining *why* it was denied.
|
||||||
deny_message = "Deletion is permanent"
|
deny_message = "Deletion is permanent"
|
||||||
|
|
||||||
# (Optional) An array of approval modes where this rule is active.
|
# (Optional) An array of approval modes where this rule is active.
|
||||||
modes = ["autoEdit"]
|
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.
|
# If omitted, the rule applies to both.
|
||||||
interactive = true
|
interactive = true
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -99,6 +99,11 @@
|
|||||||
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
|
||||||
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
|
||||||
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
{ "label": "Headless mode", "slug": "docs/cli/headless" },
|
||||||
|
{
|
||||||
|
"label": "Git worktrees",
|
||||||
|
"badge": "🔬",
|
||||||
|
"slug": "docs/cli/git-worktrees"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Hooks",
|
"label": "Hooks",
|
||||||
"collapsed": true,
|
"collapsed": true,
|
||||||
|
|||||||
+10
-12
@@ -35,13 +35,19 @@ const commonRestrictedSyntaxRules = [
|
|||||||
message:
|
message:
|
||||||
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
|
'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(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
// Global ignores
|
// Global ignores
|
||||||
ignores: [
|
ignores: [
|
||||||
'node_modules/*',
|
'**/node_modules/**',
|
||||||
'eslint.config.js',
|
'eslint.config.js',
|
||||||
'packages/**/dist/**',
|
'packages/**/dist/**',
|
||||||
'bundle/**',
|
'bundle/**',
|
||||||
@@ -50,7 +56,7 @@ export default tseslint.config(
|
|||||||
'dist/**',
|
'dist/**',
|
||||||
'evals/**',
|
'evals/**',
|
||||||
'packages/test-utils/**',
|
'packages/test-utils/**',
|
||||||
'.gemini/skills/**',
|
'.gemini/**',
|
||||||
'**/*.d.ts',
|
'**/*.d.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -133,16 +139,7 @@ export default tseslint.config(
|
|||||||
'no-cond-assign': 'error',
|
'no-cond-assign': 'error',
|
||||||
'no-debugger': 'error',
|
'no-debugger': 'error',
|
||||||
'no-duplicate-case': 'error',
|
'no-duplicate-case': 'error',
|
||||||
'no-restricted-syntax': [
|
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
|
||||||
'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-unsafe-finally': 'error',
|
||||||
'no-unused-expressions': 'off', // Disable base rule
|
'no-unused-expressions': 'off', // Disable base rule
|
||||||
'@typescript-eslint/no-unused-expressions': [
|
'@typescript-eslint/no-unused-expressions': [
|
||||||
@@ -161,6 +158,7 @@ export default tseslint.config(
|
|||||||
'@typescript-eslint/await-thenable': ['error'],
|
'@typescript-eslint/await-thenable': ['error'],
|
||||||
'@typescript-eslint/no-floating-promises': ['error'],
|
'@typescript-eslint/no-floating-promises': ['error'],
|
||||||
'@typescript-eslint/no-unnecessary-type-assertion': ['error'],
|
'@typescript-eslint/no-unnecessary-type-assertion': ['error'],
|
||||||
|
'@typescript-eslint/no-misused-spread': ['error'],
|
||||||
'no-restricted-imports': [
|
'no-restricted-imports': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,9 +15,26 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { DEFAULT_GEMINI_MODEL } from '@google/gemini-cli-core';
|
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 {
|
export interface AppEvalCase {
|
||||||
name: string;
|
name: string;
|
||||||
configOverrides?: any;
|
configOverrides?: EvalConfigOverrides;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect } from 'vitest';
|
||||||
|
import { evalTest } from './test-helper.js';
|
||||||
|
|
||||||
|
describe('CliHelpAgent Delegation', () => {
|
||||||
|
evalTest('USUALLY_PASSES', {
|
||||||
|
name: 'should delegate to cli_help agent for subagent creation questions',
|
||||||
|
params: {
|
||||||
|
settings: {
|
||||||
|
experimental: {
|
||||||
|
enableAgents: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prompt: 'Help me create a subagent in this project',
|
||||||
|
timeout: 60000,
|
||||||
|
assert: async (rig, _result) => {
|
||||||
|
const toolLogs = rig.readToolLogs();
|
||||||
|
const toolCallIndex = toolLogs.findIndex(
|
||||||
|
(log) => log.toolRequest.name === 'cli_help',
|
||||||
|
);
|
||||||
|
expect(toolCallIndex).toBeGreaterThan(-1);
|
||||||
|
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,7 +21,6 @@ describe('generalist_delegation', () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
enableAgents: true,
|
enableAgents: true,
|
||||||
},
|
},
|
||||||
excludeTools: ['run_shell_command'],
|
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
'file1.ts': 'console.log("no semi")',
|
'file1.ts': 'console.log("no semi")',
|
||||||
@@ -65,7 +64,6 @@ describe('generalist_delegation', () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
enableAgents: true,
|
enableAgents: true,
|
||||||
},
|
},
|
||||||
excludeTools: ['run_shell_command'],
|
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
'src/a.ts': 'export const a = 1;',
|
'src/a.ts': 'export const a = 1;',
|
||||||
@@ -106,7 +104,6 @@ describe('generalist_delegation', () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
enableAgents: true,
|
enableAgents: true,
|
||||||
},
|
},
|
||||||
excludeTools: ['run_shell_command'],
|
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
'README.md': 'This is a proyect.',
|
'README.md': 'This is a proyect.',
|
||||||
@@ -141,7 +138,6 @@ describe('generalist_delegation', () => {
|
|||||||
experimental: {
|
experimental: {
|
||||||
enableAgents: true,
|
enableAgents: true,
|
||||||
},
|
},
|
||||||
excludeTools: ['run_shell_command'],
|
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
'src/VERSION': '1.2.3',
|
'src/VERSION': '1.2.3',
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import { appEvalTest } from './app-test-helper.js';
|
|||||||
import { PolicyDecision } from '@google/gemini-cli-core';
|
import { PolicyDecision } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
describe('Model Steering Behavioral Evals', () => {
|
describe('Model Steering Behavioral Evals', () => {
|
||||||
appEvalTest('ALWAYS_PASSES', {
|
appEvalTest('USUALLY_PASSES', {
|
||||||
name: 'Corrective Hint: Model switches task based on hint during tool turn',
|
name: 'Corrective Hint: Model switches task based on hint during tool turn',
|
||||||
configOverrides: {
|
configOverrides: {
|
||||||
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
|
|
||||||
modelSteering: true,
|
modelSteering: true,
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
@@ -52,10 +51,9 @@ describe('Model Steering Behavioral Evals', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
appEvalTest('ALWAYS_PASSES', {
|
appEvalTest('USUALLY_PASSES', {
|
||||||
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
|
name: 'Suggestive Hint: Model incorporates user guidance mid-stream',
|
||||||
configOverrides: {
|
configOverrides: {
|
||||||
excludeTools: ['run_shell_command', 'ls', 'google_web_search'],
|
|
||||||
modelSteering: true,
|
modelSteering: true,
|
||||||
},
|
},
|
||||||
files: {},
|
files: {},
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ describe('save_memory', () => {
|
|||||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||||
evalTest('ALWAYS_PASSES', {
|
evalTest('ALWAYS_PASSES', {
|
||||||
name: rememberingFavoriteColor,
|
name: rememberingFavoriteColor,
|
||||||
params: {
|
|
||||||
settings: { tools: { core: ['save_memory'] } },
|
|
||||||
},
|
|
||||||
prompt: `remember that my favorite color is blue.
|
prompt: `remember that my favorite color is blue.
|
||||||
|
|
||||||
what is my favorite color? tell me that and surround it with $ symbol`,
|
what is my favorite color? tell me that and surround it with $ symbol`,
|
||||||
@@ -38,9 +36,7 @@ describe('save_memory', () => {
|
|||||||
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
||||||
evalTest('USUALLY_PASSES', {
|
evalTest('USUALLY_PASSES', {
|
||||||
name: rememberingCommandRestrictions,
|
name: rememberingCommandRestrictions,
|
||||||
params: {
|
|
||||||
settings: { tools: { core: ['save_memory'] } },
|
|
||||||
},
|
|
||||||
prompt: `I don't want you to ever run npm commands.`,
|
prompt: `I don't want you to ever run npm commands.`,
|
||||||
assert: async (rig, result) => {
|
assert: async (rig, result) => {
|
||||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||||
@@ -59,9 +55,7 @@ describe('save_memory', () => {
|
|||||||
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
||||||
evalTest('USUALLY_PASSES', {
|
evalTest('USUALLY_PASSES', {
|
||||||
name: rememberingWorkflow,
|
name: rememberingWorkflow,
|
||||||
params: {
|
|
||||||
settings: { tools: { core: ['save_memory'] } },
|
|
||||||
},
|
|
||||||
prompt: `I want you to always lint after building.`,
|
prompt: `I want you to always lint after building.`,
|
||||||
assert: async (rig, result) => {
|
assert: async (rig, result) => {
|
||||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||||
@@ -81,9 +75,7 @@ describe('save_memory', () => {
|
|||||||
'Agent ignores temporary conversation details';
|
'Agent ignores temporary conversation details';
|
||||||
evalTest('ALWAYS_PASSES', {
|
evalTest('ALWAYS_PASSES', {
|
||||||
name: ignoringTemporaryInformation,
|
name: ignoringTemporaryInformation,
|
||||||
params: {
|
|
||||||
settings: { tools: { core: ['save_memory'] } },
|
|
||||||
},
|
|
||||||
prompt: `I'm going to get a coffee.`,
|
prompt: `I'm going to get a coffee.`,
|
||||||
assert: async (rig, result) => {
|
assert: async (rig, result) => {
|
||||||
await rig.waitForTelemetryReady();
|
await rig.waitForTelemetryReady();
|
||||||
@@ -106,9 +98,7 @@ describe('save_memory', () => {
|
|||||||
const rememberingPetName = "Agent remembers user's pet's name";
|
const rememberingPetName = "Agent remembers user's pet's name";
|
||||||
evalTest('ALWAYS_PASSES', {
|
evalTest('ALWAYS_PASSES', {
|
||||||
name: rememberingPetName,
|
name: rememberingPetName,
|
||||||
params: {
|
|
||||||
settings: { tools: { core: ['save_memory'] } },
|
|
||||||
},
|
|
||||||
prompt: `Please remember that my dog's name is Buddy.`,
|
prompt: `Please remember that my dog's name is Buddy.`,
|
||||||
assert: async (rig, result) => {
|
assert: async (rig, result) => {
|
||||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||||
@@ -127,9 +117,7 @@ describe('save_memory', () => {
|
|||||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||||
evalTest('ALWAYS_PASSES', {
|
evalTest('ALWAYS_PASSES', {
|
||||||
name: rememberingCommandAlias,
|
name: rememberingCommandAlias,
|
||||||
params: {
|
|
||||||
settings: { tools: { core: ['save_memory'] } },
|
|
||||||
},
|
|
||||||
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
||||||
assert: async (rig, result) => {
|
assert: async (rig, result) => {
|
||||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||||
@@ -149,18 +137,6 @@ describe('save_memory', () => {
|
|||||||
"Agent ignores workspace's database schema location";
|
"Agent ignores workspace's database schema location";
|
||||||
evalTest('USUALLY_PASSES', {
|
evalTest('USUALLY_PASSES', {
|
||||||
name: ignoringDbSchemaLocation,
|
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\`.`,
|
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
|
||||||
assert: async (rig, result) => {
|
assert: async (rig, result) => {
|
||||||
await rig.waitForTelemetryReady();
|
await rig.waitForTelemetryReady();
|
||||||
@@ -180,9 +156,7 @@ describe('save_memory', () => {
|
|||||||
"Agent remembers user's coding style preference";
|
"Agent remembers user's coding style preference";
|
||||||
evalTest('ALWAYS_PASSES', {
|
evalTest('ALWAYS_PASSES', {
|
||||||
name: rememberingCodingStyle,
|
name: rememberingCodingStyle,
|
||||||
params: {
|
|
||||||
settings: { tools: { core: ['save_memory'] } },
|
|
||||||
},
|
|
||||||
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
||||||
assert: async (rig, result) => {
|
assert: async (rig, result) => {
|
||||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||||
@@ -202,18 +176,6 @@ describe('save_memory', () => {
|
|||||||
'Agent ignores workspace build artifact location';
|
'Agent ignores workspace build artifact location';
|
||||||
evalTest('USUALLY_PASSES', {
|
evalTest('USUALLY_PASSES', {
|
||||||
name: ignoringBuildArtifactLocation,
|
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.`,
|
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
|
||||||
assert: async (rig, result) => {
|
assert: async (rig, result) => {
|
||||||
await rig.waitForTelemetryReady();
|
await rig.waitForTelemetryReady();
|
||||||
@@ -232,18 +194,6 @@ describe('save_memory', () => {
|
|||||||
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
||||||
evalTest('USUALLY_PASSES', {
|
evalTest('USUALLY_PASSES', {
|
||||||
name: ignoringMainEntryPoint,
|
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\`.`,
|
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
|
||||||
assert: async (rig, result) => {
|
assert: async (rig, result) => {
|
||||||
await rig.waitForTelemetryReady();
|
await rig.waitForTelemetryReady();
|
||||||
@@ -262,9 +212,7 @@ describe('save_memory', () => {
|
|||||||
const rememberingBirthday = "Agent remembers user's birthday";
|
const rememberingBirthday = "Agent remembers user's birthday";
|
||||||
evalTest('ALWAYS_PASSES', {
|
evalTest('ALWAYS_PASSES', {
|
||||||
name: rememberingBirthday,
|
name: rememberingBirthday,
|
||||||
params: {
|
|
||||||
settings: { tools: { core: ['save_memory'] } },
|
|
||||||
},
|
|
||||||
prompt: `My birthday is on June 15th.`,
|
prompt: `My birthday is on June 15th.`,
|
||||||
assert: async (rig, result) => {
|
assert: async (rig, result) => {
|
||||||
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
const wasToolCalled = await rig.waitForToolCall('save_memory');
|
||||||
|
|||||||
+17
-1
@@ -197,9 +197,25 @@ export function symlinkNodeModules(testDir: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings that are forbidden in evals. Evals should never restrict which
|
||||||
|
* tools are available — they must test against the full, default tool set
|
||||||
|
* to ensure realistic behavior.
|
||||||
|
*/
|
||||||
|
interface ForbiddenToolSettings {
|
||||||
|
tools?: {
|
||||||
|
/** Restricting core tools in evals is forbidden. */
|
||||||
|
core?: never;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface EvalCase {
|
export interface EvalCase {
|
||||||
name: string;
|
name: string;
|
||||||
params?: Record<string, any>;
|
params?: {
|
||||||
|
settings?: ForbiddenToolSettings & Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
prompt: string;
|
prompt: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
files?: Record<string, string>;
|
files?: Record<string, string>;
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
testTimeout: 300000, // 5 minutes
|
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'],
|
reporters: ['default', 'json'],
|
||||||
outputFile: {
|
outputFile: {
|
||||||
json: 'evals/logs/report.json',
|
json: 'evals/logs/report.json',
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll open https://example.com and check the page title for you."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":35,"totalTokenCount":135}}]}
|
||||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title of https://example.com is \"Example Domain\". The browser session has been completed and cleaned up successfully."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I have opened the page and the title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
|
||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The task is complete. The page title is 'Example Domain'."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":20,"totalTokenCount":320}}]}
|
||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Done."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":400,"candidatesTokenCount":5,"totalTokenCount":405}}]}
|
||||||
|
|||||||
@@ -175,4 +175,36 @@ priority = 200
|
|||||||
expect(output).toContain('browser_agent');
|
expect(output).toContain('browser_agent');
|
||||||
expect(output).toContain('completed successfully');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,16 +34,20 @@ describe('extension install', () => {
|
|||||||
writeFileSync(testServerPath, extension);
|
writeFileSync(testServerPath, extension);
|
||||||
try {
|
try {
|
||||||
const result = await rig.runCommand(
|
const result = await rig.runCommand(
|
||||||
['extensions', 'install', `${rig.testDir!}`],
|
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||||
{ stdin: 'y\n' },
|
{ stdin: 'y\n' },
|
||||||
);
|
);
|
||||||
expect(result).toContain('test-extension-install');
|
expect(result).toContain('test-extension-install');
|
||||||
|
|
||||||
const listResult = await rig.runCommand(['extensions', 'list']);
|
const listResult = await rig.runCommand([
|
||||||
|
'--debug',
|
||||||
|
'extensions',
|
||||||
|
'list',
|
||||||
|
]);
|
||||||
expect(listResult).toContain('test-extension-install');
|
expect(listResult).toContain('test-extension-install');
|
||||||
writeFileSync(testServerPath, extensionUpdate);
|
writeFileSync(testServerPath, extensionUpdate);
|
||||||
const updateResult = await rig.runCommand(
|
const updateResult = await rig.runCommand(
|
||||||
['extensions', 'update', `test-extension-install`],
|
['--debug', 'extensions', 'update', `test-extension-install`],
|
||||||
{ stdin: 'y\n' },
|
{ stdin: 'y\n' },
|
||||||
);
|
);
|
||||||
expect(updateResult).toContain('0.0.2');
|
expect(updateResult).toContain('0.0.2');
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe('extension reloading', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await rig.runCommand(
|
const result = await rig.runCommand(
|
||||||
['extensions', 'install', `${rig.testDir!}`],
|
['--debug', 'extensions', 'install', `${rig.testDir!}`],
|
||||||
{ stdin: 'y\n' },
|
{ stdin: 'y\n' },
|
||||||
);
|
);
|
||||||
expect(result).toContain('test-extension');
|
expect(result).toContain('test-extension');
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { TestRig, poll, normalizePath } from './test-helper.js';
|
import { TestRig, poll, normalizePath } from './test-helper.js';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { writeFileSync } from 'node:fs';
|
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
describe('Hooks System Integration', () => {
|
describe('Hooks System Integration', { timeout: 120000 }, () => {
|
||||||
let rig: TestRig;
|
let rig: TestRig;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -2016,6 +2017,10 @@ console.log(JSON.stringify({
|
|||||||
|
|
||||||
// 3. Final setup with full settings
|
// 3. Final setup with full settings
|
||||||
rig.setup('Hook Disabling Multiple Ops', {
|
rig.setup('Hook Disabling Multiple Ops', {
|
||||||
|
fakeResponsesPath: join(
|
||||||
|
import.meta.dirname,
|
||||||
|
'hooks-system.disabled-via-command.responses',
|
||||||
|
),
|
||||||
settings: {
|
settings: {
|
||||||
hooksConfig: {
|
hooksConfig: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -2230,7 +2235,7 @@ console.log(JSON.stringify({
|
|||||||
|
|
||||||
// The hook should have stopped execution message (returned from tool)
|
// The hook should have stopped execution message (returned from tool)
|
||||||
expect(result).toContain(
|
expect(result).toContain(
|
||||||
'Agent execution stopped: Emergency Stop triggered by hook',
|
'Agent execution stopped by hook: Emergency Stop triggered by hook',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tool should NOT be called successfully (it was blocked/stopped)
|
// Tool should NOT be called successfully (it was blocked/stopped)
|
||||||
@@ -2242,4 +2247,210 @@ console.log(JSON.stringify({
|
|||||||
expect(writeFileCalls).toHaveLength(0);
|
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
@@ -51,7 +51,7 @@
|
|||||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
"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: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",
|
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache --max-warnings 0",
|
||||||
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
|
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
|
||||||
"lint:ci": "npm run lint:all",
|
"lint:ci": "npm run lint:all",
|
||||||
"lint:all": "node scripts/lint.js",
|
"lint:all": "node scripts/lint.js",
|
||||||
|
|||||||
@@ -12,48 +12,46 @@ import {
|
|||||||
beforeEach,
|
beforeEach,
|
||||||
afterEach,
|
afterEach,
|
||||||
type MockInstance,
|
type MockInstance,
|
||||||
type Mock,
|
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { handleInstall, installCommand } from './install.js';
|
import { handleInstall, installCommand } from './install.js';
|
||||||
import yargs from 'yargs';
|
import yargs from 'yargs';
|
||||||
import * as core from '@google/gemini-cli-core';
|
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 type { Stats } from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||||
|
|
||||||
const mockInstallOrUpdateExtension: Mock<
|
const {
|
||||||
typeof ExtensionManager.prototype.installOrUpdateExtension
|
mockInstallOrUpdateExtension,
|
||||||
> = vi.hoisted(() => vi.fn());
|
mockLoadExtensions,
|
||||||
const mockRequestConsentNonInteractive: Mock<
|
mockExtensionManager,
|
||||||
typeof requestConsentNonInteractive
|
mockRequestConsentNonInteractive,
|
||||||
> = vi.hoisted(() => vi.fn());
|
mockPromptForConsentNonInteractive,
|
||||||
const mockPromptForConsentNonInteractive: Mock<
|
mockStat,
|
||||||
typeof promptForConsentNonInteractive
|
mockInferInstallMetadata,
|
||||||
> = vi.hoisted(() => vi.fn());
|
mockIsWorkspaceTrusted,
|
||||||
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
|
mockLoadTrustedFolders,
|
||||||
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
|
mockDiscover,
|
||||||
() => vi.fn(),
|
} = vi.hoisted(() => {
|
||||||
);
|
const mockLoadExtensions = vi.fn();
|
||||||
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
|
const mockInstallOrUpdateExtension = vi.fn();
|
||||||
vi.fn(),
|
const mockExtensionManager = vi.fn().mockImplementation(() => ({
|
||||||
);
|
loadExtensions: mockLoadExtensions,
|
||||||
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
|
installOrUpdateExtension: mockInstallOrUpdateExtension,
|
||||||
vi.fn(),
|
}));
|
||||||
);
|
|
||||||
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
|
return {
|
||||||
vi.hoisted(() => vi.fn());
|
mockLoadExtensions,
|
||||||
|
mockInstallOrUpdateExtension,
|
||||||
|
mockExtensionManager,
|
||||||
|
mockRequestConsentNonInteractive: vi.fn(),
|
||||||
|
mockPromptForConsentNonInteractive: vi.fn(),
|
||||||
|
mockStat: vi.fn(),
|
||||||
|
mockInferInstallMetadata: vi.fn(),
|
||||||
|
mockIsWorkspaceTrusted: vi.fn(),
|
||||||
|
mockLoadTrustedFolders: vi.fn(),
|
||||||
|
mockDiscover: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../config/extensions/consent.js', () => ({
|
vi.mock('../../config/extensions/consent.js', () => ({
|
||||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||||
@@ -84,6 +82,7 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
|
|||||||
...(await importOriginal<
|
...(await importOriginal<
|
||||||
typeof import('../../config/extension-manager.js')
|
typeof import('../../config/extension-manager.js')
|
||||||
>()),
|
>()),
|
||||||
|
ExtensionManager: mockExtensionManager,
|
||||||
inferInstallMetadata: mockInferInstallMetadata,
|
inferInstallMetadata: mockInferInstallMetadata,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -117,19 +116,18 @@ describe('handleInstall', () => {
|
|||||||
let processSpy: MockInstance;
|
let processSpy: MockInstance;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
debugLogSpy = vi.spyOn(core.debugLogger, 'log');
|
debugLogSpy = vi
|
||||||
debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
|
.spyOn(core.debugLogger, 'log')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
debugErrorSpy = vi
|
||||||
|
.spyOn(core.debugLogger, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
processSpy = vi
|
processSpy = vi
|
||||||
.spyOn(process, 'exit')
|
.spyOn(process, 'exit')
|
||||||
.mockImplementation(() => undefined as never);
|
.mockImplementation(() => undefined as never);
|
||||||
|
|
||||||
vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
|
mockLoadExtensions.mockResolvedValue([]);
|
||||||
[],
|
mockInstallOrUpdateExtension.mockReset();
|
||||||
);
|
|
||||||
vi.spyOn(
|
|
||||||
ExtensionManager.prototype,
|
|
||||||
'installOrUpdateExtension',
|
|
||||||
).mockImplementation(mockInstallOrUpdateExtension);
|
|
||||||
|
|
||||||
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
|
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
|
||||||
mockDiscover.mockResolvedValue({
|
mockDiscover.mockResolvedValue({
|
||||||
@@ -163,12 +161,7 @@ describe('handleInstall', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mockInstallOrUpdateExtension.mockClear();
|
|
||||||
mockRequestConsentNonInteractive.mockClear();
|
|
||||||
mockStat.mockClear();
|
|
||||||
mockInferInstallMetadata.mockClear();
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function createMockExtension(
|
function createMockExtension(
|
||||||
@@ -288,6 +281,39 @@ describe('handleInstall', () => {
|
|||||||
expect(processSpy).toHaveBeenCalledWith(1);
|
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 () => {
|
it('should proceed if local path is already trusted', async () => {
|
||||||
mockInstallOrUpdateExtension.mockResolvedValue(
|
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||||
createMockExtension({
|
createMockExtension({
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface InstallArgs {
|
|||||||
autoUpdate?: boolean;
|
autoUpdate?: boolean;
|
||||||
allowPreRelease?: boolean;
|
allowPreRelease?: boolean;
|
||||||
consent?: boolean;
|
consent?: boolean;
|
||||||
|
skipSettings?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleInstall(args: InstallArgs) {
|
export async function handleInstall(args: InstallArgs) {
|
||||||
@@ -153,7 +154,7 @@ export async function handleInstall(args: InstallArgs) {
|
|||||||
const extensionManager = new ExtensionManager({
|
const extensionManager = new ExtensionManager({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
requestConsent,
|
requestConsent,
|
||||||
requestSetting: promptForSetting,
|
requestSetting: args.skipSettings ? null : promptForSetting,
|
||||||
settings,
|
settings,
|
||||||
});
|
});
|
||||||
await extensionManager.loadExtensions();
|
await extensionManager.loadExtensions();
|
||||||
@@ -196,6 +197,11 @@ export const installCommand: CommandModule = {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
})
|
})
|
||||||
|
.option('skip-settings', {
|
||||||
|
describe: 'Skip the configuration on install process.',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
.check((argv) => {
|
.check((argv) => {
|
||||||
if (!argv.source) {
|
if (!argv.source) {
|
||||||
throw new Error('The source argument must be provided.');
|
throw new Error('The source argument must be provided.');
|
||||||
@@ -214,6 +220,8 @@ export const installCommand: CommandModule = {
|
|||||||
allowPreRelease: argv['pre-release'] as boolean | undefined,
|
allowPreRelease: argv['pre-release'] as boolean | undefined,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
consent: argv['consent'] as boolean | undefined,
|
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();
|
await exitCli();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export async function getMcpServersFromConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mcpServers[key] = {
|
mcpServers[key] = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||||
...server,
|
...server,
|
||||||
extension,
|
extension,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -226,6 +226,51 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('parseArguments', () => {
|
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([
|
it.each([
|
||||||
{
|
{
|
||||||
description: 'long flags',
|
description: 'long flags',
|
||||||
@@ -1671,6 +1716,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
|||||||
|
|
||||||
const serverA = config.getMcpServers()?.['serverA'];
|
const serverA = config.getMcpServers()?.['serverA'];
|
||||||
expect(serverA).toEqual({
|
expect(serverA).toEqual({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||||
...localMcpServers['serverA'],
|
...localMcpServers['serverA'],
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
url: 'https://admin-server-a.com/sse',
|
url: 'https://admin-server-a.com/sse',
|
||||||
@@ -1721,6 +1767,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
|||||||
};
|
};
|
||||||
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
||||||
serverA: {
|
serverA: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||||
...localMcpServers['serverA'],
|
...localMcpServers['serverA'],
|
||||||
includeTools: ['local_tool'],
|
includeTools: ['local_tool'],
|
||||||
timeout: 1234,
|
timeout: 1234,
|
||||||
@@ -1763,6 +1810,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
|||||||
};
|
};
|
||||||
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
const localMcpServersWithTools: Record<string, MCPServerConfig> = {
|
||||||
serverA: {
|
serverA: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||||
...localMcpServers['serverA'],
|
...localMcpServers['serverA'],
|
||||||
includeTools: ['local_tool'],
|
includeTools: ['local_tool'],
|
||||||
},
|
},
|
||||||
@@ -2225,6 +2273,30 @@ describe('loadCliConfig tool exclusions', () => {
|
|||||||
expect(config.getExcludeTools()).toContain('ask_user');
|
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 () => {
|
it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => {
|
||||||
process.stdin.isTTY = false;
|
process.stdin.isTTY = false;
|
||||||
process.argv = [
|
process.argv = [
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import yargs from 'yargs/yargs';
|
import yargs from 'yargs';
|
||||||
import { hideBin } from 'yargs/helpers';
|
import { hideBin } from 'yargs/helpers';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import { execa } from 'execa';
|
||||||
import { mcpCommand } from '../commands/mcp.js';
|
import { mcpCommand } from '../commands/mcp.js';
|
||||||
import { extensionsCommand } from '../commands/extensions.js';
|
import { extensionsCommand } from '../commands/extensions.js';
|
||||||
import { skillsCommand } from '../commands/skills.js';
|
import { skillsCommand } from '../commands/skills.js';
|
||||||
@@ -38,6 +39,9 @@ import {
|
|||||||
applyAdminAllowlist,
|
applyAdminAllowlist,
|
||||||
applyRequiredServers,
|
applyRequiredServers,
|
||||||
getAdminBlockedMcpServersMessage,
|
getAdminBlockedMcpServersMessage,
|
||||||
|
getProjectRootForWorktree,
|
||||||
|
isGeminiWorktree,
|
||||||
|
type WorktreeSettings,
|
||||||
type HookDefinition,
|
type HookDefinition,
|
||||||
type HookEventName,
|
type HookEventName,
|
||||||
type OutputFormat,
|
type OutputFormat,
|
||||||
@@ -48,6 +52,8 @@ import {
|
|||||||
type MergedSettings,
|
type MergedSettings,
|
||||||
saveModelChange,
|
saveModelChange,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
|
isWorktreeEnabled,
|
||||||
|
type LoadedSettings,
|
||||||
} from './settings.js';
|
} from './settings.js';
|
||||||
|
|
||||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||||
@@ -74,6 +80,7 @@ export interface CliArgs {
|
|||||||
debug: boolean | undefined;
|
debug: boolean | undefined;
|
||||||
prompt: string | undefined;
|
prompt: string | undefined;
|
||||||
promptInteractive: string | undefined;
|
promptInteractive: string | undefined;
|
||||||
|
worktree?: string;
|
||||||
|
|
||||||
yolo: boolean | undefined;
|
yolo: boolean | undefined;
|
||||||
approvalMode: string | undefined;
|
approvalMode: string | undefined;
|
||||||
@@ -115,6 +122,36 @@ const coerceCommaSeparated = (values: string[]): string[] => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-parses the command line arguments to find the worktree flag.
|
||||||
|
* Used for early setup before full argument parsing with settings.
|
||||||
|
*/
|
||||||
|
export function getWorktreeArg(argv: string[]): string | undefined {
|
||||||
|
const result = yargs(hideBin(argv))
|
||||||
|
.help(false)
|
||||||
|
.version(false)
|
||||||
|
.option('worktree', { alias: 'w', type: 'string' })
|
||||||
|
.strict(false)
|
||||||
|
.exitProcess(false)
|
||||||
|
.parseSync();
|
||||||
|
|
||||||
|
if (result.worktree === undefined) return undefined;
|
||||||
|
return typeof result.worktree === 'string' ? result.worktree.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a worktree is requested via CLI and enabled in settings.
|
||||||
|
* Returns the requested name (can be empty string for auto-generated) or undefined.
|
||||||
|
*/
|
||||||
|
export function getRequestedWorktreeName(
|
||||||
|
settings: LoadedSettings,
|
||||||
|
): string | undefined {
|
||||||
|
if (!isWorktreeEnabled(settings)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return getWorktreeArg(process.argv);
|
||||||
|
}
|
||||||
|
|
||||||
export async function parseArguments(
|
export async function parseArguments(
|
||||||
settings: MergedSettings,
|
settings: MergedSettings,
|
||||||
): Promise<CliArgs> {
|
): Promise<CliArgs> {
|
||||||
@@ -158,6 +195,20 @@ export async function parseArguments(
|
|||||||
description:
|
description:
|
||||||
'Execute the provided prompt and continue in interactive mode',
|
'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', {
|
.option('sandbox', {
|
||||||
alias: 's',
|
alias: 's',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@@ -335,6 +386,9 @@ export async function parseArguments(
|
|||||||
) {
|
) {
|
||||||
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,6 +474,7 @@ export interface LoadCliConfigOptions {
|
|||||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
|
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
|
||||||
disabled?: string[];
|
disabled?: string[];
|
||||||
};
|
};
|
||||||
|
worktreeSettings?: WorktreeSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadCliConfig(
|
export async function loadCliConfig(
|
||||||
@@ -431,6 +486,9 @@ export async function loadCliConfig(
|
|||||||
const { cwd = process.cwd(), projectHooks } = options;
|
const { cwd = process.cwd(), projectHooks } = options;
|
||||||
const debugMode = isDebugMode(argv);
|
const debugMode = isDebugMode(argv);
|
||||||
|
|
||||||
|
const worktreeSettings =
|
||||||
|
options.worktreeSettings ?? (await resolveWorktreeSettings(cwd));
|
||||||
|
|
||||||
if (argv.sandbox) {
|
if (argv.sandbox) {
|
||||||
process.env['GEMINI_SANDBOX'] = 'true';
|
process.env['GEMINI_SANDBOX'] = 'true';
|
||||||
}
|
}
|
||||||
@@ -649,12 +707,16 @@ export async function loadCliConfig(
|
|||||||
|
|
||||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||||
|
|
||||||
|
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
|
||||||
|
|
||||||
// In non-interactive mode, exclude tools that require a prompt.
|
// In non-interactive mode, exclude tools that require a prompt.
|
||||||
const extraExcludes: string[] = [];
|
const extraExcludes: string[] = [];
|
||||||
if (!interactive) {
|
if (!interactive || isAcpMode) {
|
||||||
// The Policy Engine natively handles headless safety by translating ASK_USER
|
// The Policy Engine natively handles headless safety by translating ASK_USER
|
||||||
// decisions to DENY. However, we explicitly block ask_user here to guarantee
|
// 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.
|
// 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);
|
extraExcludes.push(ASK_USER_TOOL_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,7 +832,6 @@ export async function loadCliConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
|
|
||||||
let clientName: string | undefined = undefined;
|
let clientName: string | undefined = undefined;
|
||||||
if (isAcpMode) {
|
if (isAcpMode) {
|
||||||
const ide = detectIdeFromEnv();
|
const ide = detectIdeFromEnv();
|
||||||
@@ -799,6 +860,7 @@ export async function loadCliConfig(
|
|||||||
importFormat: settings.context?.importFormat,
|
importFormat: settings.context?.importFormat,
|
||||||
debugMode,
|
debugMode,
|
||||||
question,
|
question,
|
||||||
|
worktreeSettings,
|
||||||
|
|
||||||
coreTools: settings.tools?.core || undefined,
|
coreTools: settings.tools?.core || undefined,
|
||||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||||
@@ -940,3 +1002,48 @@ function mergeExcludeTools(
|
|||||||
]);
|
]);
|
||||||
return Array.from(allExcludeTools);
|
return Array.from(allExcludeTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveWorktreeSettings(
|
||||||
|
cwd: string,
|
||||||
|
): Promise<WorktreeSettings | undefined> {
|
||||||
|
let worktreePath: string | undefined;
|
||||||
|
try {
|
||||||
|
const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], {
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
const toplevel = stdout.trim();
|
||||||
|
const projectRoot = await getProjectRootForWorktree(toplevel);
|
||||||
|
|
||||||
|
if (isGeminiWorktree(toplevel, projectRoot)) {
|
||||||
|
worktreePath = toplevel;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!worktreePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let worktreeBaseSha: string | undefined;
|
||||||
|
try {
|
||||||
|
const { stdout } = await execa('git', ['rev-parse', 'HEAD'], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
});
|
||||||
|
worktreeBaseSha = stdout.trim();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
debugLogger.debug(
|
||||||
|
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!worktreeBaseSha) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: path.basename(worktreePath),
|
||||||
|
path: worktreePath,
|
||||||
|
baseSha: worktreeBaseSha,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -637,64 +637,4 @@ describe('ExtensionManager', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('orphaned extension cleanup', () => {
|
|
||||||
it('should remove broken extension metadata on startup to allow re-installation', async () => {
|
|
||||||
const extName = 'orphaned-ext';
|
|
||||||
const sourceDir = path.join(tempHomeDir, 'valid-source');
|
|
||||||
fs.mkdirSync(sourceDir, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(sourceDir, 'gemini-extension.json'),
|
|
||||||
JSON.stringify({ name: extName, version: '1.0.0' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Link an extension successfully.
|
|
||||||
await extensionManager.loadExtensions();
|
|
||||||
await extensionManager.installOrUpdateExtension({
|
|
||||||
source: sourceDir,
|
|
||||||
type: 'link',
|
|
||||||
});
|
|
||||||
|
|
||||||
const destinationPath = path.join(userExtensionsDir, extName);
|
|
||||||
const metadataPath = path.join(
|
|
||||||
destinationPath,
|
|
||||||
'.gemini-extension-install.json',
|
|
||||||
);
|
|
||||||
expect(fs.existsSync(metadataPath)).toBe(true);
|
|
||||||
|
|
||||||
// Simulate metadata corruption (e.g., pointing to a non-existent source).
|
|
||||||
fs.writeFileSync(
|
|
||||||
metadataPath,
|
|
||||||
JSON.stringify({ source: '/NON_EXISTENT_PATH', type: 'link' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Simulate CLI startup. The manager should detect the broken link
|
|
||||||
// and proactively delete the orphaned metadata directory.
|
|
||||||
const newManager = new ExtensionManager({
|
|
||||||
settings: createTestMergedSettings(),
|
|
||||||
workspaceDir: tempWorkspaceDir,
|
|
||||||
requestConsent: vi.fn().mockResolvedValue(true),
|
|
||||||
requestSetting: null,
|
|
||||||
integrityManager: mockIntegrityManager,
|
|
||||||
});
|
|
||||||
|
|
||||||
await newManager.loadExtensions();
|
|
||||||
|
|
||||||
// Verify the extension failed to load and was proactively cleaned up.
|
|
||||||
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(fs.existsSync(destinationPath)).toBe(false);
|
|
||||||
|
|
||||||
// Verify the system is self-healed and allows re-linking to the valid source.
|
|
||||||
await newManager.installOrUpdateExtension({
|
|
||||||
source: sourceDir,
|
|
||||||
type: 'link',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(newManager.getExtensions().some((e) => e.name === extName)).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -982,18 +982,11 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
plan: config.plan,
|
plan: config.plan,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const extName = path.basename(extensionDir);
|
debugLogger.error(
|
||||||
debugLogger.warn(
|
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
|
||||||
`Warning: Removing broken extension ${extName}: ${getErrorMessage(e)}`,
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,8 +249,10 @@ describe('extension tests', () => {
|
|||||||
expect(extensions[0].name).toBe('test-extension');
|
expect(extensions[0].name).toBe('test-extension');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log a warning and remove the extension if a context file path is outside the extension directory', async () => {
|
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, 'warn').mockImplementation(() => {});
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
createExtension({
|
createExtension({
|
||||||
extensionsDir: userExtensionsDir,
|
extensionsDir: userExtensionsDir,
|
||||||
name: 'traversal-extension',
|
name: 'traversal-extension',
|
||||||
@@ -660,8 +662,10 @@ name = "yolo-checker"
|
|||||||
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
|
expect(serverConfig.env!['MISSING_VAR_BRACES']).toBe('${ALSO_UNDEFINED}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove an extension with invalid JSON config and log a warning', async () => {
|
it('should skip an extension with invalid JSON config and log an error', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
// Good extension
|
// Good extension
|
||||||
createExtension({
|
createExtension({
|
||||||
@@ -682,15 +686,17 @@ name = "yolo-checker"
|
|||||||
expect(extensions[0].name).toBe('good-ext');
|
expect(extensions[0].name).toBe('good-ext');
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining(
|
expect.stringContaining(
|
||||||
`Warning: Removing broken extension bad-ext: Failed to load extension config from ${badConfigPath}`,
|
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove an extension with missing "name" in config and log a warning', async () => {
|
it('should skip an extension with missing "name" in config and log an error', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
// Good extension
|
// Good extension
|
||||||
createExtension({
|
createExtension({
|
||||||
@@ -711,7 +717,7 @@ name = "yolo-checker"
|
|||||||
expect(extensions[0].name).toBe('good-ext');
|
expect(extensions[0].name).toBe('good-ext');
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining(
|
expect.stringContaining(
|
||||||
`Warning: Removing broken extension bad-ext-no-name: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
|
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -737,8 +743,10 @@ name = "yolo-checker"
|
|||||||
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
|
expect(extensions[0].mcpServers?.['test-server'].trust).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should log a warning for invalid extension names during loading', async () => {
|
it('should log an error for invalid extension names during loading', async () => {
|
||||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const consoleSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
createExtension({
|
createExtension({
|
||||||
extensionsDir: userExtensionsDir,
|
extensionsDir: userExtensionsDir,
|
||||||
name: 'bad_name',
|
name: 'bad_name',
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function expectConsentSnapshot(consentString: string) {
|
async function expectConsentSnapshot(consentString: string) {
|
||||||
const renderResult = render(React.createElement(Text, null, consentString));
|
const renderResult = await render(
|
||||||
await renderResult.waitUntilReady();
|
React.createElement(Text, null, consentString),
|
||||||
|
);
|
||||||
await expect(renderResult).toMatchSvgSnapshot();
|
await expect(renderResult).toMatchSvgSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
Storage: {
|
Storage: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||||
...actual.Storage,
|
...actual.Storage,
|
||||||
getGlobalGeminiDir: () => '/virtual-home/.gemini',
|
getGlobalGeminiDir: () => '/virtual-home/.gemini',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -632,6 +632,10 @@ export function resetSettingsCacheForTesting() {
|
|||||||
settingsCache.clear();
|
settingsCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWorktreeEnabled(settings: LoadedSettings): boolean {
|
||||||
|
return settings.merged.experimental.worktrees;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads settings from user and workspace directories.
|
* Loads settings from user and workspace directories.
|
||||||
* Project settings override user settings.
|
* Project settings override user settings.
|
||||||
|
|||||||
@@ -538,8 +538,32 @@ describe('SettingsSchema', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const visitJsonSchema = (jsonSchema: Record<string, unknown>) => {
|
||||||
|
const ref = jsonSchema['ref'];
|
||||||
|
if (typeof ref === 'string') {
|
||||||
|
referenced.add(ref);
|
||||||
|
}
|
||||||
|
const properties = jsonSchema['properties'];
|
||||||
|
if (
|
||||||
|
properties &&
|
||||||
|
typeof properties === 'object' &&
|
||||||
|
!Array.isArray(properties)
|
||||||
|
) {
|
||||||
|
Object.values(properties as Record<string, unknown>).forEach((prop) =>
|
||||||
|
visitJsonSchema(prop as Record<string, unknown>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const items = jsonSchema['items'];
|
||||||
|
if (items && typeof items === 'object' && !Array.isArray(items)) {
|
||||||
|
visitJsonSchema(items as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Object.values(schema).forEach(visitDefinition);
|
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.
|
// Ensure definitions map doesn't accumulate stale entries.
|
||||||
Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => {
|
Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => {
|
||||||
if (!referenced.has(key)) {
|
if (!referenced.has(key)) {
|
||||||
|
|||||||
@@ -1094,7 +1094,7 @@ const SETTINGS_SCHEMA = {
|
|||||||
showInDialog: false,
|
showInDialog: false,
|
||||||
additionalProperties: {
|
additionalProperties: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
ref: 'ModelPolicy',
|
ref: 'ModelPolicyChain',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1198,6 +1198,26 @@ const SETTINGS_SCHEMA = {
|
|||||||
'Disable user input on browser window during automation.',
|
'Disable user input on browser window during automation.',
|
||||||
showInDialog: false,
|
showInDialog: false,
|
||||||
},
|
},
|
||||||
|
confirmSensitiveActions: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Confirm Sensitive Actions',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Require manual confirmation for sensitive browser actions (e.g., fill_form, evaluate_script).',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
blockFileUploads: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Block File Uploads',
|
||||||
|
category: 'Advanced',
|
||||||
|
requiresRestart: true,
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Hard-block file upload requests from the browser agent.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1906,6 +1926,16 @@ const SETTINGS_SCHEMA = {
|
|||||||
description: 'Enable local and remote subagents.',
|
description: 'Enable local and remote subagents.',
|
||||||
showInDialog: false,
|
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: {
|
extensionManagement: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Extension Management',
|
label: 'Extension Management',
|
||||||
@@ -2988,6 +3018,14 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ModelPolicyChain: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'A chain of model policies for fallback behavior.',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
ref: 'ModelPolicy',
|
||||||
|
},
|
||||||
|
},
|
||||||
ModelPolicy: {
|
ModelPolicy: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
clearInstance: vi.fn(),
|
clearInstance: vi.fn(),
|
||||||
},
|
},
|
||||||
coreEvents: {
|
coreEvents: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||||
...actual.coreEvents,
|
...actual.coreEvents,
|
||||||
emitFeedback: vi.fn(),
|
emitFeedback: vi.fn(),
|
||||||
emitConsoleLog: vi.fn(),
|
emitConsoleLog: vi.fn(),
|
||||||
@@ -199,6 +200,8 @@ vi.mock('./config/config.js', () => ({
|
|||||||
networkAccess: false,
|
networkAccess: false,
|
||||||
}),
|
}),
|
||||||
isDebugMode: vi.fn(() => false),
|
isDebugMode: vi.fn(() => false),
|
||||||
|
getRequestedWorktreeName: vi.fn(() => undefined),
|
||||||
|
getWorktreeArg: vi.fn(() => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('read-package-up', () => ({
|
vi.mock('read-package-up', () => ({
|
||||||
@@ -1506,6 +1509,7 @@ describe('startInteractiveUI', () => {
|
|||||||
.spyOn(process.stdout, 'write')
|
.spyOn(process.stdout, 'write')
|
||||||
.mockImplementation(() => true);
|
.mockImplementation(() => true);
|
||||||
const mockConfigWithScreenReader = {
|
const mockConfigWithScreenReader = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||||
...mockConfig,
|
...mockConfig,
|
||||||
getScreenReader: () => screenReader,
|
getScreenReader: () => screenReader,
|
||||||
} as Config;
|
} as Config;
|
||||||
|
|||||||
+43
-15
@@ -9,6 +9,7 @@ import {
|
|||||||
WarningPriority,
|
WarningPriority,
|
||||||
type Config,
|
type Config,
|
||||||
type ResumedSessionData,
|
type ResumedSessionData,
|
||||||
|
type WorktreeInfo,
|
||||||
type OutputPayload,
|
type OutputPayload,
|
||||||
type ConsoleLogPayload,
|
type ConsoleLogPayload,
|
||||||
type UserFeedbackPayload,
|
type UserFeedbackPayload,
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
ValidationRequiredError,
|
ValidationRequiredError,
|
||||||
type AdminControlsSettings,
|
type AdminControlsSettings,
|
||||||
debugLogger,
|
debugLogger,
|
||||||
|
isHeadlessMode,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||||
@@ -63,6 +65,7 @@ import {
|
|||||||
registerTelemetryConfig,
|
registerTelemetryConfig,
|
||||||
setupSignalHandlers,
|
setupSignalHandlers,
|
||||||
} from './utils/cleanup.js';
|
} from './utils/cleanup.js';
|
||||||
|
import { setupWorktree } from './utils/worktreeSetup.js';
|
||||||
import {
|
import {
|
||||||
cleanupToolOutputFiles,
|
cleanupToolOutputFiles,
|
||||||
cleanupExpiredSessions,
|
cleanupExpiredSessions,
|
||||||
@@ -210,6 +213,37 @@ export async function main() {
|
|||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
loadSettingsHandle?.end();
|
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
|
// Report settings errors once during startup
|
||||||
settings.errors.forEach((error) => {
|
settings.errors.forEach((error) => {
|
||||||
coreEvents.emitFeedback('warning', error.message);
|
coreEvents.emitFeedback('warning', error.message);
|
||||||
@@ -223,15 +257,7 @@ export async function main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all([
|
const argv = await argvPromise;
|
||||||
cleanupCheckpoints(),
|
|
||||||
cleanupToolOutputFiles(settings.merged),
|
|
||||||
cleanupBackgroundLogs(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
|
||||||
const argv = await parseArguments(settings.merged);
|
|
||||||
parseArgsHandle?.end();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
||||||
@@ -271,6 +297,7 @@ export async function main() {
|
|||||||
const isDebugMode = cliConfig.isDebugMode(argv);
|
const isDebugMode = cliConfig.isDebugMode(argv);
|
||||||
const consolePatcher = new ConsolePatcher({
|
const consolePatcher = new ConsolePatcher({
|
||||||
stderr: true,
|
stderr: true,
|
||||||
|
interactive: isHeadlessMode() ? false : true,
|
||||||
debugMode: isDebugMode,
|
debugMode: isDebugMode,
|
||||||
onNewMessage: (msg) => {
|
onNewMessage: (msg) => {
|
||||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||||
@@ -426,6 +453,7 @@ export async function main() {
|
|||||||
const loadConfigHandle = startupProfiler.start('load_cli_config');
|
const loadConfigHandle = startupProfiler.start('load_cli_config');
|
||||||
const config = await loadCliConfig(settings.merged, sessionId, argv, {
|
const config = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||||
projectHooks: settings.workspace.settings.hooks,
|
projectHooks: settings.workspace.settings.hooks,
|
||||||
|
worktreeSettings: worktreeInfo,
|
||||||
});
|
});
|
||||||
loadConfigHandle?.end();
|
loadConfigHandle?.end();
|
||||||
|
|
||||||
@@ -457,12 +485,10 @@ export async function main() {
|
|||||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup sessions after config initialization
|
// Launch cleanup expired sessions as a background task
|
||||||
try {
|
cleanupExpiredSessions(config, settings.merged).catch((e) => {
|
||||||
await cleanupExpiredSessions(config, settings.merged);
|
|
||||||
} catch (e) {
|
|
||||||
debugLogger.error('Failed to cleanup expired sessions:', e);
|
debugLogger.error('Failed to cleanup expired sessions:', e);
|
||||||
}
|
});
|
||||||
|
|
||||||
if (config.getListExtensions()) {
|
if (config.getListExtensions()) {
|
||||||
debugLogger.log('Installed extensions:');
|
debugLogger.log('Installed extensions:');
|
||||||
@@ -514,7 +540,9 @@ export async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const terminalHandle = startupProfiler.start('setup_terminal');
|
||||||
await setupTerminalAndTheme(config, settings);
|
await setupTerminalAndTheme(config, settings);
|
||||||
|
terminalHandle?.end();
|
||||||
|
|
||||||
const initAppHandle = startupProfiler.start('initialize_app');
|
const initAppHandle = startupProfiler.start('initialize_app');
|
||||||
const initializationResult = await initializeApp(config, settings);
|
const initializationResult = await initializeApp(config, settings);
|
||||||
@@ -538,7 +566,7 @@ export async function main() {
|
|||||||
isAlternateBufferEnabled(config),
|
isAlternateBufferEnabled(config),
|
||||||
config.getScreenReader(),
|
config.getScreenReader(),
|
||||||
);
|
);
|
||||||
const rawStartupWarnings = await getStartupWarnings();
|
const rawStartupWarnings = await rawStartupWarningsPromise;
|
||||||
const startupWarnings: StartupWarning[] = [
|
const startupWarnings: StartupWarning[] = [
|
||||||
...rawStartupWarnings.map((message) => ({
|
...rawStartupWarnings.map((message) => ({
|
||||||
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
|
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ vi.mock('./config/config.js', () => ({
|
|||||||
} as unknown as Config),
|
} as unknown as Config),
|
||||||
parseArguments: vi.fn().mockResolvedValue({}),
|
parseArguments: vi.fn().mockResolvedValue({}),
|
||||||
isDebugMode: vi.fn(() => false),
|
isDebugMode: vi.fn(() => false),
|
||||||
|
getRequestedWorktreeName: vi.fn(() => undefined),
|
||||||
|
getWorktreeArg: vi.fn(() => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('read-package-up', () => ({
|
vi.mock('read-package-up', () => ({
|
||||||
|
|||||||
@@ -1137,6 +1137,7 @@ describe('runNonInteractive', () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
processStderrSpy.mock.calls.some(
|
processStderrSpy.mock.calls.some(
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
(call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
|
(call) => typeof call[0] === 'string' && call[0].includes('Cancelling'),
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export async function runNonInteractive({
|
|||||||
return promptIdContext.run(prompt_id, async () => {
|
return promptIdContext.run(prompt_id, async () => {
|
||||||
const consolePatcher = new ConsolePatcher({
|
const consolePatcher = new ConsolePatcher({
|
||||||
stderr: true,
|
stderr: true,
|
||||||
|
interactive: false,
|
||||||
debugMode: config.getDebugMode(),
|
debugMode: config.getDebugMode(),
|
||||||
onNewMessage: (msg) => {
|
onNewMessage: (msg) => {
|
||||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ describe('BuiltinCommandLoader', () => {
|
|||||||
|
|
||||||
it('should include policies command when message bus integration is enabled', async () => {
|
it('should include policies command when message bus integration is enabled', async () => {
|
||||||
const mockConfigWithMessageBus = {
|
const mockConfigWithMessageBus = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||||
...mockConfig,
|
...mockConfig,
|
||||||
getEnableHooks: () => false,
|
getEnableHooks: () => false,
|
||||||
getMcpEnabled: () => true,
|
getMcpEnabled: () => true,
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { AppContainer } from '../ui/AppContainer.js';
|
import { AppContainer } from '../ui/AppContainer.js';
|
||||||
import { renderWithProviders, type RenderInstance } from './render.js';
|
import {
|
||||||
|
renderWithProviders,
|
||||||
|
type RenderInstance,
|
||||||
|
persistentStateMock,
|
||||||
|
} from './render.js';
|
||||||
import {
|
import {
|
||||||
makeFakeConfig,
|
makeFakeConfig,
|
||||||
type Config,
|
type Config,
|
||||||
@@ -180,6 +184,11 @@ export class AppRig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
persistentStateMock.setData({
|
||||||
|
terminalSetupPromptShown: true,
|
||||||
|
tipsShown: 10,
|
||||||
|
});
|
||||||
|
|
||||||
this.setupEnvironment();
|
this.setupEnvironment();
|
||||||
resetSettingsCacheForTesting();
|
resetSettingsCacheForTesting();
|
||||||
this.settings = this.createRigSettings();
|
this.settings = this.createRigSettings();
|
||||||
@@ -226,6 +235,8 @@ export class AppRig {
|
|||||||
private setupEnvironment() {
|
private setupEnvironment() {
|
||||||
// Stub environment variables to avoid interference from developer's machine
|
// Stub environment variables to avoid interference from developer's machine
|
||||||
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
|
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
|
||||||
|
vi.stubEnv('TERM_PROGRAM', 'other');
|
||||||
|
vi.stubEnv('VSCODE_GIT_IPC_HANDLE', '');
|
||||||
if (this.options.fakeResponsesPath) {
|
if (this.options.fakeResponsesPath) {
|
||||||
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||||
MockShellExecutionService.setPassthrough(false);
|
MockShellExecutionService.setPassthrough(false);
|
||||||
@@ -291,7 +302,6 @@ export class AppRig {
|
|||||||
|
|
||||||
const newContentGeneratorConfig = {
|
const newContentGeneratorConfig = {
|
||||||
authType: authMethod,
|
authType: authMethod,
|
||||||
|
|
||||||
proxy: gcConfig.getProxy(),
|
proxy: gcConfig.getProxy(),
|
||||||
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
|
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export async function toMatchSvgSnapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const { isNot } = this as any;
|
const { isNot } = this as any;
|
||||||
let pass = true;
|
let pass = true;
|
||||||
const invalidLines: Array<{ line: number; content: string }> = [];
|
const invalidLines: Array<{ line: number; content: string }> = [];
|
||||||
@@ -108,7 +108,6 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
||||||
expect.extend({
|
expect.extend({
|
||||||
toHaveOnlyValidCharacters,
|
toHaveOnlyValidCharacters,
|
||||||
toMatchSvgSnapshot,
|
toMatchSvgSnapshot,
|
||||||
|
|||||||
@@ -37,14 +37,12 @@ export const createMockCommandContext = (
|
|||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
agentContext: null,
|
agentContext: null,
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
merged: defaultMergedSettings,
|
merged: defaultMergedSettings,
|
||||||
setValue: vi.fn(),
|
setValue: vi.fn(),
|
||||||
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
forScope: vi.fn().mockReturnValue({ settings: {} }),
|
||||||
} as unknown as LoadedSettings,
|
} as unknown as LoadedSettings,
|
||||||
git: undefined as GitService | undefined,
|
git: undefined as GitService | undefined,
|
||||||
|
|
||||||
logger: {
|
logger: {
|
||||||
log: vi.fn(),
|
log: vi.fn(),
|
||||||
logMessage: vi.fn(),
|
logMessage: vi.fn(),
|
||||||
@@ -53,7 +51,6 @@ export const createMockCommandContext = (
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any, // Cast because Logger is a class.
|
} as any, // Cast because Logger is a class.
|
||||||
},
|
},
|
||||||
|
|
||||||
ui: {
|
ui: {
|
||||||
addItem: vi.fn(),
|
addItem: vi.fn(),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
@@ -72,7 +69,6 @@ export const createMockCommandContext = (
|
|||||||
} as any,
|
} as any,
|
||||||
session: {
|
session: {
|
||||||
sessionShellAllowlist: new Set<string>(),
|
sessionShellAllowlist: new Set<string>(),
|
||||||
|
|
||||||
stats: {
|
stats: {
|
||||||
sessionStartTime: new Date(),
|
sessionStartTime: new Date(),
|
||||||
lastPromptTokenCount: 0,
|
lastPromptTokenCount: 0,
|
||||||
@@ -98,7 +94,6 @@ export const createMockCommandContext = (
|
|||||||
for (const key in source) {
|
for (const key in source) {
|
||||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
const sourceValue = source[key];
|
const sourceValue = source[key];
|
||||||
|
|
||||||
const targetValue = output[key];
|
const targetValue = output[key];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -109,7 +104,6 @@ export const createMockCommandContext = (
|
|||||||
output[key] = merge(targetValue, sourceValue);
|
output[key] = merge(targetValue, sourceValue);
|
||||||
} else {
|
} else {
|
||||||
// If not, we do a direct assignment. This preserves Date objects and others.
|
// If not, we do a direct assignment. This preserves Date objects and others.
|
||||||
|
|
||||||
output[key] = sourceValue;
|
output[key] = sourceValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
|||||||
getDeleteSession: vi.fn(() => undefined),
|
getDeleteSession: vi.fn(() => undefined),
|
||||||
setSessionId: vi.fn(),
|
setSessionId: vi.fn(),
|
||||||
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
|
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
|
||||||
|
getWorktreeSettings: vi.fn(() => undefined),
|
||||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
|
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
|
||||||
getAcpMode: vi.fn(() => false),
|
getAcpMode: vi.fn(() => false),
|
||||||
isBrowserLaunchSuppressed: vi.fn(() => false),
|
isBrowserLaunchSuppressed: vi.fn(() => false),
|
||||||
|
|||||||
@@ -12,24 +12,18 @@ import { waitFor } from './async.js';
|
|||||||
|
|
||||||
describe('render', () => {
|
describe('render', () => {
|
||||||
it('should render a component', async () => {
|
it('should render a component', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(<Text>Hello World</Text>);
|
||||||
<Text>Hello World</Text>,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toBe('Hello World\n');
|
expect(lastFrame()).toBe('Hello World\n');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support rerender', async () => {
|
it('should support rerender', async () => {
|
||||||
const { lastFrame, rerender, waitUntilReady, unmount } = render(
|
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
|
||||||
<Text>Hello</Text>,
|
<Text>Hello</Text>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toBe('Hello\n');
|
expect(lastFrame()).toBe('Hello\n');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => rerender(<Text>World</Text>));
|
||||||
rerender(<Text>World</Text>);
|
|
||||||
});
|
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(lastFrame()).toBe('World\n');
|
expect(lastFrame()).toBe('World\n');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -42,10 +36,8 @@ describe('render', () => {
|
|||||||
return <Text>Hello</Text>;
|
return <Text>Hello</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { unmount, waitUntilReady } = render(<TestComponent />);
|
const { unmount } = await render(<TestComponent />);
|
||||||
await waitUntilReady();
|
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
expect(cleanupMock).toHaveBeenCalled();
|
expect(cleanupMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -54,36 +46,27 @@ describe('renderHook', () => {
|
|||||||
it('should rerender with previous props when called without arguments', async () => {
|
it('should rerender with previous props when called without arguments', async () => {
|
||||||
const useTestHook = ({ value }: { value: number }) => {
|
const useTestHook = ({ value }: { value: number }) => {
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => setCount((c) => c + 1), [value]);
|
||||||
setCount((c) => c + 1);
|
|
||||||
}, [value]);
|
|
||||||
return { count, value };
|
return { count, value };
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result, rerender, waitUntilReady, unmount } = renderHook(
|
const { result, rerender, waitUntilReady, unmount } = await renderHook(
|
||||||
useTestHook,
|
useTestHook,
|
||||||
{
|
{ initialProps: { value: 1 } },
|
||||||
initialProps: { value: 1 },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(result.current.value).toBe(1);
|
expect(result.current.value).toBe(1);
|
||||||
await waitFor(() => expect(result.current.count).toBe(1));
|
await waitFor(() => expect(result.current.count).toBe(1));
|
||||||
|
|
||||||
// Rerender with new props
|
// Rerender with new props
|
||||||
await act(async () => {
|
await act(async () => rerender({ value: 2 }));
|
||||||
rerender({ value: 2 });
|
|
||||||
});
|
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(result.current.value).toBe(2);
|
expect(result.current.value).toBe(2);
|
||||||
await waitFor(() => expect(result.current.count).toBe(2));
|
await waitFor(() => expect(result.current.count).toBe(2));
|
||||||
|
|
||||||
// Rerender without arguments should use previous props (value: 2)
|
// Rerender without arguments should use previous props (value: 2)
|
||||||
// This would previously crash or pass undefined if not fixed
|
// This would previously crash or pass undefined if not fixed
|
||||||
await act(async () => {
|
await act(async () => rerender());
|
||||||
rerender();
|
|
||||||
});
|
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(result.current.value).toBe(2);
|
expect(result.current.value).toBe(2);
|
||||||
// Count should not increase because value didn't change
|
// Count should not increase because value didn't change
|
||||||
@@ -98,14 +81,11 @@ describe('renderHook', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { result, rerender, waitUntilReady, unmount } =
|
const { result, rerender, waitUntilReady, unmount } =
|
||||||
renderHook(useTestHook);
|
await renderHook(useTestHook);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(result.current.count).toBe(0);
|
expect(result.current.count).toBe(0);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => rerender());
|
||||||
rerender();
|
|
||||||
});
|
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(result.current.count).toBe(0);
|
expect(result.current.count).toBe(0);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -113,19 +93,14 @@ describe('renderHook', () => {
|
|||||||
|
|
||||||
it('should update props if undefined is passed explicitly', async () => {
|
it('should update props if undefined is passed explicitly', async () => {
|
||||||
const useTestHook = (val: string | undefined) => val;
|
const useTestHook = (val: string | undefined) => val;
|
||||||
const { result, rerender, waitUntilReady, unmount } = renderHook(
|
const { result, rerender, waitUntilReady, unmount } = await renderHook(
|
||||||
useTestHook,
|
useTestHook,
|
||||||
{
|
{ initialProps: 'initial' },
|
||||||
initialProps: 'initial' as string | undefined,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(result.current).toBe('initial');
|
expect(result.current).toBe('initial');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => rerender(undefined));
|
||||||
rerender(undefined);
|
|
||||||
});
|
|
||||||
await waitUntilReady();
|
await waitUntilReady();
|
||||||
expect(result.current).toBeUndefined();
|
expect(result.current).toBeUndefined();
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -257,13 +257,9 @@ class XtermStdout extends EventEmitter {
|
|||||||
return currentFrame !== '';
|
return currentFrame !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// If both are empty, it's a match.
|
// If Ink expects nothing (no new static content and no dynamic output),
|
||||||
// We consider undefined lastRenderOutput as effectively empty for this check
|
// we consider it a match because the terminal buffer will just hold the historical static content.
|
||||||
// to support hook testing where Ink may skip rendering completely.
|
if (expectedFrame === '') {
|
||||||
if (
|
|
||||||
(this.lastRenderOutput === undefined || expectedFrame === '') &&
|
|
||||||
currentFrame === ''
|
|
||||||
) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,8 +267,8 @@ class XtermStdout extends EventEmitter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match.
|
// If the terminal is empty but Ink expects something, it's not a match.
|
||||||
if (expectedFrame === '' || currentFrame === '') {
|
if (currentFrame === '') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,15 +376,21 @@ export type RenderInstance = {
|
|||||||
capturedOverflowActions: OverflowActions | undefined;
|
capturedOverflowActions: OverflowActions | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RenderWithProvidersInstance = RenderInstance & {
|
||||||
|
simulateClick: (
|
||||||
|
col: number,
|
||||||
|
row: number,
|
||||||
|
button?: 0 | 1 | 2,
|
||||||
|
) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
const instances: InkInstance[] = [];
|
const instances: InkInstance[] = [];
|
||||||
|
|
||||||
// Wrapper around ink's render that ensures act() is called and uses Xterm for output
|
export const render = async (
|
||||||
export const render = (
|
|
||||||
tree: React.ReactElement,
|
tree: React.ReactElement,
|
||||||
terminalWidth?: number,
|
terminalWidth?: number,
|
||||||
): Omit<
|
): Promise<
|
||||||
RenderInstance,
|
Omit<RenderInstance, 'capturedOverflowState' | 'capturedOverflowActions'>
|
||||||
'capturedOverflowState' | 'capturedOverflowActions'
|
|
||||||
> => {
|
> => {
|
||||||
const cols = terminalWidth ?? 100;
|
const cols = terminalWidth ?? 100;
|
||||||
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
|
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
|
||||||
@@ -437,6 +439,8 @@ export const render = (
|
|||||||
|
|
||||||
instances.push(instance);
|
instances.push(instance);
|
||||||
|
|
||||||
|
await stdout.waitUntilReady();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rerender: (newTree: React.ReactElement) => {
|
rerender: (newTree: React.ReactElement) => {
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -622,15 +626,7 @@ export const renderWithProviders = async (
|
|||||||
};
|
};
|
||||||
appState?: AppState;
|
appState?: AppState;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<
|
): Promise<RenderWithProvidersInstance> => {
|
||||||
RenderInstance & {
|
|
||||||
simulateClick: (
|
|
||||||
col: number,
|
|
||||||
row: number,
|
|
||||||
button?: 0 | 1 | 2,
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
|
||||||
> => {
|
|
||||||
const baseState: UIState = new Proxy(
|
const baseState: UIState = new Proxy(
|
||||||
{ ...baseMockUiState, ...providedUiState },
|
{ ...baseMockUiState, ...providedUiState },
|
||||||
{
|
{
|
||||||
@@ -751,7 +747,10 @@ export const renderWithProviders = async (
|
|||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderResult = render(wrapWithProviders(component), terminalWidth);
|
const renderResult = await render(
|
||||||
|
wrapWithProviders(component),
|
||||||
|
terminalWidth,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...renderResult,
|
...renderResult,
|
||||||
@@ -765,21 +764,20 @@ export const renderWithProviders = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderHook<Result, Props>(
|
export async function renderHook<Result, Props>(
|
||||||
renderCallback: (props: Props) => Result,
|
renderCallback: (props: Props) => Result,
|
||||||
options?: {
|
options?: {
|
||||||
initialProps?: Props;
|
initialProps?: Props;
|
||||||
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
|
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
|
||||||
},
|
},
|
||||||
): {
|
): Promise<{
|
||||||
result: { current: Result };
|
result: { current: Result };
|
||||||
rerender: (props?: Props) => void;
|
rerender: (props?: Props) => void;
|
||||||
unmount: () => void;
|
unmount: () => void;
|
||||||
waitUntilReady: () => Promise<void>;
|
waitUntilReady: () => Promise<void>;
|
||||||
generateSvg: () => string;
|
generateSvg: () => string;
|
||||||
} {
|
}> {
|
||||||
const result = { current: undefined as unknown as Result };
|
const result = { current: undefined as unknown as Result };
|
||||||
|
|
||||||
let currentProps = options?.initialProps as Props;
|
let currentProps = options?.initialProps as Props;
|
||||||
|
|
||||||
function TestComponent({
|
function TestComponent({
|
||||||
@@ -800,17 +798,15 @@ export function renderHook<Result, Props>(
|
|||||||
let waitUntilReady: () => Promise<void> = async () => {};
|
let waitUntilReady: () => Promise<void> = async () => {};
|
||||||
let generateSvg: () => string = () => '';
|
let generateSvg: () => string = () => '';
|
||||||
|
|
||||||
act(() => {
|
const renderResult = await render(
|
||||||
const renderResult = render(
|
<Wrapper>
|
||||||
<Wrapper>
|
<TestComponent renderCallback={renderCallback} props={currentProps} />
|
||||||
<TestComponent renderCallback={renderCallback} props={currentProps} />
|
</Wrapper>,
|
||||||
</Wrapper>,
|
);
|
||||||
);
|
inkRerender = renderResult.rerender;
|
||||||
inkRerender = renderResult.rerender;
|
unmount = renderResult.unmount;
|
||||||
unmount = renderResult.unmount;
|
waitUntilReady = renderResult.waitUntilReady;
|
||||||
waitUntilReady = renderResult.waitUntilReady;
|
generateSvg = renderResult.generateSvg;
|
||||||
generateSvg = renderResult.generateSvg;
|
|
||||||
});
|
|
||||||
|
|
||||||
function rerender(props?: Props) {
|
function rerender(props?: Props) {
|
||||||
if (arguments.length > 0) {
|
if (arguments.length > 0) {
|
||||||
@@ -864,7 +860,7 @@ export async function renderHookWithProviders<Result, Props>(
|
|||||||
|
|
||||||
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
|
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
|
||||||
|
|
||||||
let renderResult: ReturnType<typeof render>;
|
let renderResult: RenderWithProvidersInstance;
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
renderResult = await renderWithProviders(
|
renderResult = await renderWithProviders(
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export const createMockSettings = (
|
|||||||
workspace,
|
workspace,
|
||||||
isTrusted,
|
isTrusted,
|
||||||
errors,
|
errors,
|
||||||
|
|
||||||
merged: mergedOverride,
|
merged: mergedOverride,
|
||||||
...settingsOverrides
|
...settingsOverrides
|
||||||
} = overrides;
|
} = overrides;
|
||||||
@@ -61,7 +60,6 @@ export const createMockSettings = (
|
|||||||
settings: settingsOverrides,
|
settings: settingsOverrides,
|
||||||
originalSettings: settingsOverrides,
|
originalSettings: settingsOverrides,
|
||||||
},
|
},
|
||||||
|
|
||||||
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
|
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
|
||||||
isTrusted ?? true,
|
isTrusted ?? true,
|
||||||
errors || [],
|
errors || [],
|
||||||
|
|||||||
@@ -94,14 +94,10 @@ describe('App', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('should render main content and composer when not quitting', async () => {
|
it('should render main content and composer when not quitting', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState: mockUIState,
|
||||||
{
|
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
||||||
uiState: mockUIState,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Tips for getting started');
|
expect(lastFrame()).toContain('Tips for getting started');
|
||||||
expect(lastFrame()).toContain('Notifications');
|
expect(lastFrame()).toContain('Notifications');
|
||||||
@@ -115,14 +111,10 @@ describe('App', () => {
|
|||||||
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
|
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
|
||||||
} as UIState;
|
} as UIState;
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState: quittingUIState,
|
||||||
{
|
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
||||||
uiState: quittingUIState,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Quitting...');
|
expect(lastFrame()).toContain('Quitting...');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -136,14 +128,10 @@ describe('App', () => {
|
|||||||
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
|
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
|
||||||
} as UIState;
|
} as UIState;
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState: quittingUIState,
|
||||||
{
|
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||||
uiState: quittingUIState,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('HistoryItemDisplay');
|
expect(lastFrame()).toContain('HistoryItemDisplay');
|
||||||
expect(lastFrame()).toContain('Quitting...');
|
expect(lastFrame()).toContain('Quitting...');
|
||||||
@@ -156,14 +144,10 @@ describe('App', () => {
|
|||||||
dialogsVisible: true,
|
dialogsVisible: true,
|
||||||
} as UIState;
|
} as UIState;
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState: dialogUIState,
|
||||||
{
|
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||||
uiState: dialogUIState,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Tips for getting started');
|
expect(lastFrame()).toContain('Tips for getting started');
|
||||||
expect(lastFrame()).toContain('Notifications');
|
expect(lastFrame()).toContain('Notifications');
|
||||||
@@ -183,14 +167,10 @@ describe('App', () => {
|
|||||||
[stateKey]: true,
|
[stateKey]: true,
|
||||||
} as UIState;
|
} as UIState;
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState,
|
||||||
{
|
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||||
uiState,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
|
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -200,14 +180,10 @@ describe('App', () => {
|
|||||||
it('should render ScreenReaderAppLayout when screen reader is enabled', async () => {
|
it('should render ScreenReaderAppLayout when screen reader is enabled', async () => {
|
||||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState: mockUIState,
|
||||||
{
|
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||||
uiState: mockUIState,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Notifications');
|
expect(lastFrame()).toContain('Notifications');
|
||||||
expect(lastFrame()).toContain('Footer');
|
expect(lastFrame()).toContain('Footer');
|
||||||
@@ -219,14 +195,10 @@ describe('App', () => {
|
|||||||
it('should render DefaultAppLayout when screen reader is not enabled', async () => {
|
it('should render DefaultAppLayout when screen reader is not enabled', async () => {
|
||||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState: mockUIState,
|
||||||
{
|
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||||
uiState: mockUIState,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Tips for getting started');
|
expect(lastFrame()).toContain('Tips for getting started');
|
||||||
expect(lastFrame()).toContain('Notifications');
|
expect(lastFrame()).toContain('Notifications');
|
||||||
@@ -274,15 +246,11 @@ describe('App', () => {
|
|||||||
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
|
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
|
||||||
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
|
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState: stateWithConfirmingTool,
|
||||||
{
|
config: configWithExperiment,
|
||||||
uiState: stateWithConfirmingTool,
|
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||||
config: configWithExperiment,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Tips for getting started');
|
expect(lastFrame()).toContain('Tips for getting started');
|
||||||
expect(lastFrame()).toContain('Notifications');
|
expect(lastFrame()).toContain('Notifications');
|
||||||
@@ -295,28 +263,20 @@ describe('App', () => {
|
|||||||
describe('Snapshots', () => {
|
describe('Snapshots', () => {
|
||||||
it('renders default layout correctly', async () => {
|
it('renders default layout correctly', async () => {
|
||||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState: mockUIState,
|
||||||
{
|
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||||
uiState: mockUIState,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders screen reader layout correctly', async () => {
|
it('renders screen reader layout correctly', async () => {
|
||||||
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState: mockUIState,
|
||||||
{
|
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||||
uiState: mockUIState,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -326,14 +286,10 @@ describe('App', () => {
|
|||||||
...mockUIState,
|
...mockUIState,
|
||||||
dialogsVisible: true,
|
dialogsVisible: true,
|
||||||
} as UIState;
|
} as UIState;
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<App />, {
|
||||||
<App />,
|
uiState: dialogUIState,
|
||||||
{
|
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||||
uiState: dialogUIState,
|
});
|
||||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,7 @@ describe('IdeIntegrationNudge', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(debugLogger.warn).mockImplementation((...args) => {
|
vi.mocked(debugLogger.warn).mockImplementation((...args) => {
|
||||||
if (
|
if (
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
typeof args[0] === 'string' &&
|
typeof args[0] === 'string' &&
|
||||||
/was not wrapped in act/.test(args[0])
|
/was not wrapped in act/.test(args[0])
|
||||||
) {
|
) {
|
||||||
@@ -53,10 +54,9 @@ describe('IdeIntegrationNudge', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly with default options', async () => {
|
it('renders correctly with default options', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<IdeIntegrationNudge {...defaultProps} />,
|
<IdeIntegrationNudge {...defaultProps} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const frame = lastFrame();
|
const frame = lastFrame();
|
||||||
|
|
||||||
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
|
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
|
||||||
@@ -72,8 +72,6 @@ describe('IdeIntegrationNudge', () => {
|
|||||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
|
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// "Yes" is the first option and selected by default usually.
|
// "Yes" is the first option and selected by default usually.
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
stdin.write('\r');
|
stdin.write('\r');
|
||||||
@@ -93,8 +91,6 @@ describe('IdeIntegrationNudge', () => {
|
|||||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
|
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Navigate down to "No (esc)"
|
// Navigate down to "No (esc)"
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
stdin.write('\u001B[B'); // Down arrow
|
stdin.write('\u001B[B'); // Down arrow
|
||||||
@@ -119,8 +115,6 @@ describe('IdeIntegrationNudge', () => {
|
|||||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
|
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Navigate down to "No, don't ask again"
|
// Navigate down to "No, don't ask again"
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
stdin.write('\u001B[B'); // Down arrow
|
stdin.write('\u001B[B'); // Down arrow
|
||||||
@@ -150,8 +144,6 @@ describe('IdeIntegrationNudge', () => {
|
|||||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
|
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Press Escape
|
// Press Escape
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
stdin.write('\u001B');
|
stdin.write('\u001B');
|
||||||
@@ -178,8 +170,6 @@ describe('IdeIntegrationNudge', () => {
|
|||||||
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
|
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
const frame = lastFrame();
|
const frame = lastFrame();
|
||||||
|
|
||||||
expect(frame).toContain(
|
expect(frame).toContain(
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
exports[`App > Snapshots > renders default layout correctly 1`] = `
|
||||||
"
|
"
|
||||||
▝▜▄ Gemini CLI v1.2.3
|
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||||
▝▜▄
|
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||||
▗▟▀
|
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||||
▝▀
|
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||||
|
|
||||||
|
Gemini CLI v1.2.3
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Tips for getting started:
|
Tips for getting started:
|
||||||
@@ -31,9 +34,6 @@ Tips for getting started:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -47,10 +47,13 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
|
|||||||
"Notifications
|
"Notifications
|
||||||
Footer
|
Footer
|
||||||
|
|
||||||
▝▜▄ Gemini CLI v1.2.3
|
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||||
▝▜▄
|
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||||
▗▟▀
|
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||||
▝▀
|
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||||
|
|
||||||
|
Gemini CLI v1.2.3
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Tips for getting started:
|
Tips for getting started:
|
||||||
@@ -64,12 +67,12 @@ Composer
|
|||||||
|
|
||||||
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
exports[`App > Snapshots > renders with dialogs visible 1`] = `
|
||||||
"
|
"
|
||||||
▝▜▄ Gemini CLI v1.2.3
|
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||||
▝▜▄
|
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||||
▗▟▀
|
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||||
▝▀
|
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||||
|
|
||||||
|
|
||||||
|
Gemini CLI v1.2.3
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -107,10 +110,13 @@ DialogManager
|
|||||||
|
|
||||||
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
|
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:
|
Tips for getting started:
|
||||||
@@ -140,9 +146,6 @@ HistoryItemDisplay
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Notifications
|
Notifications
|
||||||
Composer
|
Composer
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -73,23 +73,21 @@ describe('ApiAuthDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly', async () => {
|
it('renders correctly', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with a defaultValue', async () => {
|
it('renders with a defaultValue', async () => {
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(
|
||||||
<ApiAuthDialog
|
<ApiAuthDialog
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
defaultValue="test-key"
|
defaultValue="test-key"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(mockedUseTextBuffer).toHaveBeenCalledWith(
|
expect(mockedUseTextBuffer).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
initialText: 'test-key',
|
initialText: 'test-key',
|
||||||
@@ -113,10 +111,9 @@ describe('ApiAuthDialog', () => {
|
|||||||
'calls $expectedCall.name when $keyName is pressed',
|
'calls $expectedCall.name when $keyName is pressed',
|
||||||
async ({ keyName, sequence, expectedCall, args }) => {
|
async ({ keyName, sequence, expectedCall, args }) => {
|
||||||
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(
|
||||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
|
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
|
||||||
// calls[1] is the TextInput's useKeypress (typing handler)
|
// calls[1] is the TextInput's useKeypress (typing handler)
|
||||||
const keypressHandler = mockedUseKeypress.mock.calls[1][0];
|
const keypressHandler = mockedUseKeypress.mock.calls[1][0];
|
||||||
@@ -136,24 +133,22 @@ describe('ApiAuthDialog', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it('displays an error message', async () => {
|
it('displays an error message', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ApiAuthDialog
|
<ApiAuthDialog
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
error="Invalid API Key"
|
error="Invalid API Key"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Invalid API Key');
|
expect(lastFrame()).toContain('Invalid API Key');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
|
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(
|
||||||
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
// Call 0 is ApiAuthDialog (isActive: true)
|
// Call 0 is ApiAuthDialog (isActive: true)
|
||||||
// Call 1 is TextInput (isActive: true, priority: true)
|
// Call 1 is TextInput (isActive: true, priority: true)
|
||||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||||
|
|||||||
@@ -143,10 +143,9 @@ describe('AuthDialog', () => {
|
|||||||
for (const [key, value] of Object.entries(env)) {
|
for (const [key, value] of Object.entries(env)) {
|
||||||
vi.stubEnv(key, value as string);
|
vi.stubEnv(key, value as string);
|
||||||
}
|
}
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<AuthDialog {...props} />,
|
<AuthDialog {...props} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||||
for (const item of shouldContain) {
|
for (const item of shouldContain) {
|
||||||
expect(items).toContainEqual(item);
|
expect(items).toContainEqual(item);
|
||||||
@@ -161,10 +160,7 @@ describe('AuthDialog', () => {
|
|||||||
|
|
||||||
it('filters auth types when enforcedType is set', async () => {
|
it('filters auth types when enforcedType is set', async () => {
|
||||||
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||||
expect(items).toHaveLength(1);
|
expect(items).toHaveLength(1);
|
||||||
expect(items[0].value).toBe(AuthType.USE_GEMINI);
|
expect(items[0].value).toBe(AuthType.USE_GEMINI);
|
||||||
@@ -173,10 +169,7 @@ describe('AuthDialog', () => {
|
|||||||
|
|
||||||
it('sets initial index to 0 when enforcedType is set', async () => {
|
it('sets initial index to 0 when enforcedType is set', async () => {
|
||||||
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
expect(initialIndex).toBe(0);
|
expect(initialIndex).toBe(0);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -213,10 +206,7 @@ describe('AuthDialog', () => {
|
|||||||
},
|
},
|
||||||
])('selects initial auth type $desc', async ({ setup, expected }) => {
|
])('selects initial auth type $desc', async ({ setup, expected }) => {
|
||||||
setup();
|
setup();
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
expect(items[initialIndex].value).toBe(expected);
|
expect(items[initialIndex].value).toBe(expected);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -226,10 +216,7 @@ describe('AuthDialog', () => {
|
|||||||
describe('handleAuthSelect', () => {
|
describe('handleAuthSelect', () => {
|
||||||
it('calls onAuthError if validation fails', async () => {
|
it('calls onAuthError if validation fails', async () => {
|
||||||
mockedValidateAuthMethod.mockReturnValue('Invalid method');
|
mockedValidateAuthMethod.mockReturnValue('Invalid method');
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect: handleAuthSelect } =
|
const { onSelect: handleAuthSelect } =
|
||||||
mockedRadioButtonSelect.mock.calls[0][0];
|
mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
handleAuthSelect(AuthType.USE_GEMINI);
|
handleAuthSelect(AuthType.USE_GEMINI);
|
||||||
@@ -245,10 +232,7 @@ describe('AuthDialog', () => {
|
|||||||
|
|
||||||
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
|
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
|
||||||
mockedValidateAuthMethod.mockReturnValue(null);
|
mockedValidateAuthMethod.mockReturnValue(null);
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect: handleAuthSelect } =
|
const { onSelect: handleAuthSelect } =
|
||||||
mockedRadioButtonSelect.mock.calls[0][0];
|
mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
|
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
|
||||||
@@ -261,10 +245,7 @@ describe('AuthDialog', () => {
|
|||||||
|
|
||||||
it('sets auth context with empty object for other auth types', async () => {
|
it('sets auth context with empty object for other auth types', async () => {
|
||||||
mockedValidateAuthMethod.mockReturnValue(null);
|
mockedValidateAuthMethod.mockReturnValue(null);
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect: handleAuthSelect } =
|
const { onSelect: handleAuthSelect } =
|
||||||
mockedRadioButtonSelect.mock.calls[0][0];
|
mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||||
@@ -278,10 +259,7 @@ describe('AuthDialog', () => {
|
|||||||
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
|
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
|
||||||
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
|
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
|
||||||
|
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect: handleAuthSelect } =
|
const { onSelect: handleAuthSelect } =
|
||||||
mockedRadioButtonSelect.mock.calls[0][0];
|
mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||||
@@ -297,10 +275,7 @@ describe('AuthDialog', () => {
|
|||||||
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
|
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
|
||||||
// props.settings.merged.security.auth.selectedType is undefined here
|
// props.settings.merged.security.auth.selectedType is undefined here
|
||||||
|
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect: handleAuthSelect } =
|
const { onSelect: handleAuthSelect } =
|
||||||
mockedRadioButtonSelect.mock.calls[0][0];
|
mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||||
@@ -316,10 +291,7 @@ describe('AuthDialog', () => {
|
|||||||
// process.env['GEMINI_API_KEY'] is not set
|
// process.env['GEMINI_API_KEY'] is not set
|
||||||
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
|
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
|
||||||
|
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect: handleAuthSelect } =
|
const { onSelect: handleAuthSelect } =
|
||||||
mockedRadioButtonSelect.mock.calls[0][0];
|
mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||||
@@ -337,10 +309,7 @@ describe('AuthDialog', () => {
|
|||||||
props.settings.merged.security.auth.selectedType =
|
props.settings.merged.security.auth.selectedType =
|
||||||
AuthType.LOGIN_WITH_GOOGLE;
|
AuthType.LOGIN_WITH_GOOGLE;
|
||||||
|
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect: handleAuthSelect } =
|
const { onSelect: handleAuthSelect } =
|
||||||
mockedRadioButtonSelect.mock.calls[0][0];
|
mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||||
@@ -360,10 +329,7 @@ describe('AuthDialog', () => {
|
|||||||
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
|
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
|
||||||
mockedValidateAuthMethod.mockReturnValue(null);
|
mockedValidateAuthMethod.mockReturnValue(null);
|
||||||
|
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect: handleAuthSelect } =
|
const { onSelect: handleAuthSelect } =
|
||||||
mockedRadioButtonSelect.mock.calls[0][0];
|
mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -383,10 +349,9 @@ describe('AuthDialog', () => {
|
|||||||
|
|
||||||
it('displays authError when provided', async () => {
|
it('displays authError when provided', async () => {
|
||||||
props.authError = 'Something went wrong';
|
props.authError = 'Something went wrong';
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AuthDialog {...props} />,
|
<AuthDialog {...props} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('Something went wrong');
|
expect(lastFrame()).toContain('Something went wrong');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -429,10 +394,7 @@ describe('AuthDialog', () => {
|
|||||||
},
|
},
|
||||||
])('$desc', async ({ setup, expectations }) => {
|
])('$desc', async ({ setup, expectations }) => {
|
||||||
setup();
|
setup();
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||||
<AuthDialog {...props} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||||
keypressHandler({ name: 'escape' });
|
keypressHandler({ name: 'escape' });
|
||||||
expectations(props);
|
expectations(props);
|
||||||
@@ -442,30 +404,27 @@ describe('AuthDialog', () => {
|
|||||||
|
|
||||||
describe('Snapshots', () => {
|
describe('Snapshots', () => {
|
||||||
it('renders correctly with default props', async () => {
|
it('renders correctly with default props', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AuthDialog {...props} />,
|
<AuthDialog {...props} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly with auth error', async () => {
|
it('renders correctly with auth error', async () => {
|
||||||
props.authError = 'Something went wrong';
|
props.authError = 'Something went wrong';
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AuthDialog {...props} />,
|
<AuthDialog {...props} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly with enforced auth type', async () => {
|
it('renders correctly with enforced auth type', async () => {
|
||||||
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AuthDialog {...props} />,
|
<AuthDialog {...props} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ describe('AuthInProgress', () => {
|
|||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.mocked(debugLogger.error).mockImplementation((...args) => {
|
vi.mocked(debugLogger.error).mockImplementation((...args) => {
|
||||||
if (
|
if (
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
typeof args[0] === 'string' &&
|
typeof args[0] === 'string' &&
|
||||||
args[0].includes('was not wrapped in act')
|
args[0].includes('was not wrapped in act')
|
||||||
) {
|
) {
|
||||||
@@ -55,20 +56,18 @@ describe('AuthInProgress', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders initial state with spinner', async () => {
|
it('renders initial state with spinner', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<AuthInProgress onTimeout={onTimeout} />,
|
<AuthInProgress onTimeout={onTimeout} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('[Spinner] Waiting for authentication...');
|
expect(lastFrame()).toContain('[Spinner] Waiting for authentication...');
|
||||||
expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel');
|
expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onTimeout when ESC is pressed', async () => {
|
it('calls onTimeout when ESC is pressed', async () => {
|
||||||
const { waitUntilReady, unmount } = render(
|
const { waitUntilReady, unmount } = await render(
|
||||||
<AuthInProgress onTimeout={onTimeout} />,
|
<AuthInProgress onTimeout={onTimeout} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
|
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -84,10 +83,9 @@ describe('AuthInProgress', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls onTimeout when Ctrl+C is pressed', async () => {
|
it('calls onTimeout when Ctrl+C is pressed', async () => {
|
||||||
const { waitUntilReady, unmount } = render(
|
const { waitUntilReady, unmount } = await render(
|
||||||
<AuthInProgress onTimeout={onTimeout} />,
|
<AuthInProgress onTimeout={onTimeout} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
|
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -100,10 +98,9 @@ describe('AuthInProgress', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
|
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, waitUntilReady, unmount } = await render(
|
||||||
<AuthInProgress onTimeout={onTimeout} />,
|
<AuthInProgress onTimeout={onTimeout} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(180000);
|
vi.advanceTimersByTime(180000);
|
||||||
@@ -116,10 +113,7 @@ describe('AuthInProgress', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clears timer on unmount', async () => {
|
it('clears timer on unmount', async () => {
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(<AuthInProgress onTimeout={onTimeout} />);
|
||||||
<AuthInProgress onTimeout={onTimeout} />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -73,14 +73,13 @@ describe('BannedAccountDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders the suspension message from accountSuspensionInfo', async () => {
|
it('renders the suspension message from accountSuspensionInfo', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<BannedAccountDialog
|
<BannedAccountDialog
|
||||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
onChangeAuth={onChangeAuth}
|
onChangeAuth={onChangeAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const frame = lastFrame();
|
const frame = lastFrame();
|
||||||
expect(frame).toContain('Account Suspended');
|
expect(frame).toContain('Account Suspended');
|
||||||
expect(frame).toContain('violation of Terms of Service');
|
expect(frame).toContain('violation of Terms of Service');
|
||||||
@@ -89,14 +88,13 @@ describe('BannedAccountDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders menu options with appeal link text from response', async () => {
|
it('renders menu options with appeal link text from response', async () => {
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<BannedAccountDialog
|
<BannedAccountDialog
|
||||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
onChangeAuth={onChangeAuth}
|
onChangeAuth={onChangeAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||||
expect(items).toHaveLength(3);
|
expect(items).toHaveLength(3);
|
||||||
expect(items[0].label).toBe('Appeal Here');
|
expect(items[0].label).toBe('Appeal Here');
|
||||||
@@ -109,14 +107,13 @@ describe('BannedAccountDialog', () => {
|
|||||||
const infoWithoutUrl: AccountSuspensionInfo = {
|
const infoWithoutUrl: AccountSuspensionInfo = {
|
||||||
message: 'Account suspended.',
|
message: 'Account suspended.',
|
||||||
};
|
};
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<BannedAccountDialog
|
<BannedAccountDialog
|
||||||
accountSuspensionInfo={infoWithoutUrl}
|
accountSuspensionInfo={infoWithoutUrl}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
onChangeAuth={onChangeAuth}
|
onChangeAuth={onChangeAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||||
expect(items).toHaveLength(2);
|
expect(items).toHaveLength(2);
|
||||||
expect(items[0].label).toBe('Change authentication');
|
expect(items[0].label).toBe('Change authentication');
|
||||||
@@ -129,28 +126,26 @@ describe('BannedAccountDialog', () => {
|
|||||||
message: 'Account suspended.',
|
message: 'Account suspended.',
|
||||||
appealUrl: 'https://example.com/appeal',
|
appealUrl: 'https://example.com/appeal',
|
||||||
};
|
};
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<BannedAccountDialog
|
<BannedAccountDialog
|
||||||
accountSuspensionInfo={infoWithoutLinkText}
|
accountSuspensionInfo={infoWithoutLinkText}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
onChangeAuth={onChangeAuth}
|
onChangeAuth={onChangeAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
|
||||||
expect(items[0].label).toBe('Open the Google Form');
|
expect(items[0].label).toBe('Open the Google Form');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens browser when appeal option is selected', async () => {
|
it('opens browser when appeal option is selected', async () => {
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<BannedAccountDialog
|
<BannedAccountDialog
|
||||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
onChangeAuth={onChangeAuth}
|
onChangeAuth={onChangeAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
await onSelect('open_form');
|
await onSelect('open_form');
|
||||||
expect(mockedOpenBrowser).toHaveBeenCalledWith(
|
expect(mockedOpenBrowser).toHaveBeenCalledWith(
|
||||||
@@ -162,14 +157,13 @@ describe('BannedAccountDialog', () => {
|
|||||||
|
|
||||||
it('shows URL when browser cannot be launched', async () => {
|
it('shows URL when browser cannot be launched', async () => {
|
||||||
mockedShouldLaunchBrowser.mockReturnValue(false);
|
mockedShouldLaunchBrowser.mockReturnValue(false);
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<BannedAccountDialog
|
<BannedAccountDialog
|
||||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
onChangeAuth={onChangeAuth}
|
onChangeAuth={onChangeAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
onSelect('open_form');
|
onSelect('open_form');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -180,14 +174,13 @@ describe('BannedAccountDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls onExit when "Exit" is selected', async () => {
|
it('calls onExit when "Exit" is selected', async () => {
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<BannedAccountDialog
|
<BannedAccountDialog
|
||||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
onChangeAuth={onChangeAuth}
|
onChangeAuth={onChangeAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
await onSelect('exit');
|
await onSelect('exit');
|
||||||
expect(mockedRunExitCleanup).toHaveBeenCalled();
|
expect(mockedRunExitCleanup).toHaveBeenCalled();
|
||||||
@@ -196,14 +189,13 @@ describe('BannedAccountDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('calls onChangeAuth when "Change authentication" is selected', async () => {
|
it('calls onChangeAuth when "Change authentication" is selected', async () => {
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<BannedAccountDialog
|
<BannedAccountDialog
|
||||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
onChangeAuth={onChangeAuth}
|
onChangeAuth={onChangeAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
onSelect('change_auth');
|
onSelect('change_auth');
|
||||||
expect(onChangeAuth).toHaveBeenCalled();
|
expect(onChangeAuth).toHaveBeenCalled();
|
||||||
@@ -212,14 +204,13 @@ describe('BannedAccountDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('exits on escape key', async () => {
|
it('exits on escape key', async () => {
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<BannedAccountDialog
|
<BannedAccountDialog
|
||||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
onChangeAuth={onChangeAuth}
|
onChangeAuth={onChangeAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||||
const result = keypressHandler({ name: 'escape' });
|
const result = keypressHandler({ name: 'escape' });
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
@@ -227,14 +218,13 @@ describe('BannedAccountDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders snapshot correctly', async () => {
|
it('renders snapshot correctly', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<BannedAccountDialog
|
<BannedAccountDialog
|
||||||
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
onChangeAuth={onChangeAuth}
|
onChangeAuth={onChangeAuth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,25 +45,23 @@ describe('LoginWithGoogleRestartDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly', async () => {
|
it('renders correctly', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<LoginWithGoogleRestartDialog
|
<LoginWithGoogleRestartDialog
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
config={mockConfig}
|
config={mockConfig}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onDismiss when escape is pressed', async () => {
|
it('calls onDismiss when escape is pressed', async () => {
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(
|
||||||
<LoginWithGoogleRestartDialog
|
<LoginWithGoogleRestartDialog
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
config={mockConfig}
|
config={mockConfig}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||||
|
|
||||||
keypressHandler({
|
keypressHandler({
|
||||||
@@ -83,13 +81,12 @@ describe('LoginWithGoogleRestartDialog', () => {
|
|||||||
async (keyName) => {
|
async (keyName) => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(
|
||||||
<LoginWithGoogleRestartDialog
|
<LoginWithGoogleRestartDialog
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
config={mockConfig}
|
config={mockConfig}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||||
|
|
||||||
keypressHandler({
|
keypressHandler({
|
||||||
|
|||||||
@@ -4,15 +4,8 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
describe,
|
import { act } from 'react';
|
||||||
it,
|
|
||||||
expect,
|
|
||||||
vi,
|
|
||||||
beforeEach,
|
|
||||||
afterEach,
|
|
||||||
type Mock,
|
|
||||||
} from 'vitest';
|
|
||||||
import { renderHook } from '../../test-utils/render.js';
|
import { renderHook } from '../../test-utils/render.js';
|
||||||
import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js';
|
import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js';
|
||||||
import {
|
import {
|
||||||
@@ -22,7 +15,6 @@ import {
|
|||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { AuthState } from '../types.js';
|
import { AuthState } from '../types.js';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
import { waitFor } from '../../test-utils/async.js';
|
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
const mockLoadApiKey = vi.fn();
|
const mockLoadApiKey = vi.fn();
|
||||||
@@ -142,171 +134,202 @@ describe('useAuth', () => {
|
|||||||
},
|
},
|
||||||
}) as LoadedSettings;
|
}) 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 () => {
|
it('should initialize with Unauthenticated state', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = await renderHook(() =>
|
||||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
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);
|
expect(result.current.authState).toBe(AuthState.Unauthenticated);
|
||||||
|
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(result.current.authState).toBe(AuthState.Authenticated);
|
deferredRefreshAuth.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.current.authState).toBe(AuthState.Authenticated);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set error if no auth type is selected and no env key', async () => {
|
it('should set error if no auth type is selected and no env key', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = await renderHook(() =>
|
||||||
useAuthCommand(createSettings(undefined), mockConfig),
|
useAuthCommand(createSettings(undefined), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
// This happens synchronously, no deferred promise
|
||||||
expect(result.current.authError).toBe(
|
expect(result.current.authError).toBe(
|
||||||
'No authentication method selected.',
|
'No authentication method selected.',
|
||||||
);
|
);
|
||||||
expect(result.current.authState).toBe(AuthState.Updating);
|
expect(result.current.authState).toBe(AuthState.Updating);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set error if no auth type is selected but env key exists', async () => {
|
it('should set error if no auth type is selected but env key exists', async () => {
|
||||||
process.env['GEMINI_API_KEY'] = 'env-key';
|
process.env['GEMINI_API_KEY'] = 'env-key';
|
||||||
const { result } = renderHook(() =>
|
const { result } = await renderHook(() =>
|
||||||
useAuthCommand(createSettings(undefined), mockConfig),
|
useAuthCommand(createSettings(undefined), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(result.current.authError).toContain(
|
||||||
expect(result.current.authError).toContain(
|
'Existing API key detected (GEMINI_API_KEY)',
|
||||||
'Existing API key detected (GEMINI_API_KEY)',
|
);
|
||||||
);
|
expect(result.current.authState).toBe(AuthState.Updating);
|
||||||
expect(result.current.authState).toBe(AuthState.Updating);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => {
|
it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => {
|
||||||
mockLoadApiKey.mockResolvedValue(null);
|
let deferredLoadKey: { resolve: (k: string | null) => void };
|
||||||
const { result } = renderHook(() =>
|
mockLoadApiKey.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
deferredLoadKey = { resolve };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = await renderHook(() =>
|
||||||
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput);
|
deferredLoadKey.resolve(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should authenticate if USE_GEMINI and key is found', async () => {
|
it('should authenticate if USE_GEMINI and key is found', async () => {
|
||||||
mockLoadApiKey.mockResolvedValue('stored-key');
|
let deferredLoadKey: { resolve: (k: string | null) => void };
|
||||||
const { result } = renderHook(() =>
|
mockLoadApiKey.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
deferredLoadKey = { resolve };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = await renderHook(() =>
|
||||||
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
deferredLoadKey.resolve('stored-key');
|
||||||
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 () => {
|
it('should authenticate if USE_GEMINI and env key is found', async () => {
|
||||||
mockLoadApiKey.mockResolvedValue(null);
|
|
||||||
process.env['GEMINI_API_KEY'] = 'env-key';
|
process.env['GEMINI_API_KEY'] = 'env-key';
|
||||||
const { result } = renderHook(() =>
|
|
||||||
|
const { result } = await renderHook(() =>
|
||||||
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
deferredRefreshAuth.resolve();
|
||||||
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 () => {
|
it('should prioritize env key over stored key when both are present', async () => {
|
||||||
mockLoadApiKey.mockResolvedValue('stored-key');
|
|
||||||
process.env['GEMINI_API_KEY'] = 'env-key';
|
process.env['GEMINI_API_KEY'] = 'env-key';
|
||||||
const { result } = renderHook(() =>
|
|
||||||
|
const { result } = await renderHook(() =>
|
||||||
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
deferredRefreshAuth.resolve();
|
||||||
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 () => {
|
it('should set error if validation fails', async () => {
|
||||||
mockValidateAuthMethod.mockReturnValue('Validation Failed');
|
mockValidateAuthMethod.mockReturnValue('Validation Failed');
|
||||||
const { result } = renderHook(() =>
|
const { result } = await renderHook(() =>
|
||||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(result.current.authError).toBe('Validation Failed');
|
||||||
expect(result.current.authError).toBe('Validation Failed');
|
expect(result.current.authState).toBe(AuthState.Updating);
|
||||||
expect(result.current.authState).toBe(AuthState.Updating);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => {
|
it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => {
|
||||||
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE';
|
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE';
|
||||||
const { result } = renderHook(() =>
|
const { result } = await renderHook(() =>
|
||||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(result.current.authError).toContain(
|
||||||
expect(result.current.authError).toContain(
|
'Invalid value for GEMINI_DEFAULT_AUTH_TYPE',
|
||||||
'Invalid value for GEMINI_DEFAULT_AUTH_TYPE',
|
);
|
||||||
);
|
expect(result.current.authState).toBe(AuthState.Updating);
|
||||||
expect(result.current.authState).toBe(AuthState.Updating);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should authenticate successfully for valid auth type', async () => {
|
it('should authenticate successfully for valid auth type', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = await renderHook(() =>
|
||||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
deferredRefreshAuth.resolve();
|
||||||
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 () => {
|
it('should handle refreshAuth failure', async () => {
|
||||||
(mockConfig.refreshAuth as Mock).mockRejectedValue(
|
const { result } = await renderHook(() =>
|
||||||
new Error('Auth Failed'),
|
|
||||||
);
|
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(result.current.authError).toContain('Failed to sign in');
|
deferredRefreshAuth.reject(new Error('Auth Failed'));
|
||||||
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 () => {
|
it('should handle ProjectIdRequiredError without "Failed to login" prefix', async () => {
|
||||||
const projectIdError = new ProjectIdRequiredError();
|
const projectIdError = new ProjectIdRequiredError();
|
||||||
(mockConfig.refreshAuth as Mock).mockRejectedValue(projectIdError);
|
const { result } = await renderHook(() =>
|
||||||
const { result } = renderHook(() =>
|
|
||||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await act(async () => {
|
||||||
expect(result.current.authError).toBe(
|
deferredRefreshAuth.reject(projectIdError);
|
||||||
'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,10 +710,14 @@ describe('extensionsCommand', () => {
|
|||||||
size: 100,
|
size: 100,
|
||||||
} as Stats);
|
} as Stats);
|
||||||
await linkAction!(mockContext, packageName);
|
await linkAction!(mockContext, packageName);
|
||||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||||
source: packageName,
|
{
|
||||||
type: 'link',
|
source: packageName,
|
||||||
});
|
type: 'link',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: `Linking extension from "${packageName}"...`,
|
text: `Linking extension from "${packageName}"...`,
|
||||||
@@ -733,10 +737,14 @@ describe('extensionsCommand', () => {
|
|||||||
} as Stats);
|
} as Stats);
|
||||||
|
|
||||||
await linkAction!(mockContext, packageName);
|
await linkAction!(mockContext, packageName);
|
||||||
expect(mockInstallExtension).toHaveBeenCalledWith({
|
expect(mockInstallExtension).toHaveBeenCalledWith(
|
||||||
source: packageName,
|
{
|
||||||
type: 'link',
|
source: packageName,
|
||||||
});
|
type: 'link',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
|
||||||
type: MessageType.ERROR,
|
type: MessageType.ERROR,
|
||||||
text: `Failed to link extension from "${packageName}": ${errorMessage}`,
|
text: `Failed to link extension from "${packageName}": ${errorMessage}`,
|
||||||
|
|||||||
@@ -286,6 +286,11 @@ async function exploreAction(
|
|||||||
await installAction(context, extension.url, requestConsentOverride);
|
await installAction(context, extension.url, requestConsentOverride);
|
||||||
context.ui.removeComponent();
|
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(),
|
onClose: () => context.ui.removeComponent(),
|
||||||
extensionManager,
|
extensionManager,
|
||||||
}),
|
}),
|
||||||
@@ -533,7 +538,11 @@ async function installAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function linkAction(context: CommandContext, args: string) {
|
async function linkAction(
|
||||||
|
context: CommandContext,
|
||||||
|
args: string,
|
||||||
|
requestConsentOverride?: (consent: string) => Promise<boolean>,
|
||||||
|
) {
|
||||||
const extensionLoader =
|
const extensionLoader =
|
||||||
context.services.agentContext?.config.getExtensionLoader();
|
context.services.agentContext?.config.getExtensionLoader();
|
||||||
if (!(extensionLoader instanceof ExtensionManager)) {
|
if (!(extensionLoader instanceof ExtensionManager)) {
|
||||||
@@ -582,8 +591,11 @@ async function linkAction(context: CommandContext, args: string) {
|
|||||||
source: sourceFilepath,
|
source: sourceFilepath,
|
||||||
type: 'link',
|
type: 'link',
|
||||||
};
|
};
|
||||||
const extension =
|
const extension = await extensionLoader.installOrUpdateExtension(
|
||||||
await extensionLoader.installOrUpdateExtension(installMetadata);
|
installMetadata,
|
||||||
|
undefined,
|
||||||
|
requestConsentOverride,
|
||||||
|
);
|
||||||
context.ui.addItem({
|
context.ui.addItem({
|
||||||
type: MessageType.INFO,
|
type: MessageType.INFO,
|
||||||
text: `Extension "${extension.name}" linked successfully.`,
|
text: `Extension "${extension.name}" linked successfully.`,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
coreEvents: {
|
coreEvents: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||||
...actual.coreEvents,
|
...actual.coreEvents,
|
||||||
emitFeedback: vi.fn(),
|
emitFeedback: vi.fn(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ describe('AboutBox', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('renders with required props', async () => {
|
it('renders with required props', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AboutBox {...defaultProps} />,
|
<AboutBox {...defaultProps} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('About Gemini CLI');
|
expect(output).toContain('About Gemini CLI');
|
||||||
expect(output).toContain('1.0.0');
|
expect(output).toContain('1.0.0');
|
||||||
@@ -46,10 +45,9 @@ describe('AboutBox', () => {
|
|||||||
['tier', 'Enterprise', 'Tier'],
|
['tier', 'Enterprise', 'Tier'],
|
||||||
])('renders optional prop %s', async (prop, value, label) => {
|
])('renders optional prop %s', async (prop, value, label) => {
|
||||||
const props = { ...defaultProps, [prop]: value };
|
const props = { ...defaultProps, [prop]: value };
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AboutBox {...props} />,
|
<AboutBox {...props} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain(label);
|
expect(output).toContain(label);
|
||||||
expect(output).toContain(value);
|
expect(output).toContain(value);
|
||||||
@@ -58,10 +56,9 @@ describe('AboutBox', () => {
|
|||||||
|
|
||||||
it('renders Auth Method with email when userEmail is provided', async () => {
|
it('renders Auth Method with email when userEmail is provided', async () => {
|
||||||
const props = { ...defaultProps, userEmail: 'test@example.com' };
|
const props = { ...defaultProps, userEmail: 'test@example.com' };
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AboutBox {...props} />,
|
<AboutBox {...props} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('Signed in with Google (test@example.com)');
|
expect(output).toContain('Signed in with Google (test@example.com)');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -69,10 +66,9 @@ describe('AboutBox', () => {
|
|||||||
|
|
||||||
it('renders Auth Method correctly when not oauth', async () => {
|
it('renders Auth Method correctly when not oauth', async () => {
|
||||||
const props = { ...defaultProps, selectedAuthType: 'api-key' };
|
const props = { ...defaultProps, selectedAuthType: 'api-key' };
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AboutBox {...props} />,
|
<AboutBox {...props} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('api-key');
|
expect(output).toContain('api-key');
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -17,15 +17,14 @@ describe('AdminSettingsChangedDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly', async () => {
|
it('renders correctly', async () => {
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
const { lastFrame } = await renderWithProviders(
|
||||||
<AdminSettingsChangedDialog />,
|
<AdminSettingsChangedDialog />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('restarts on "r" key press', async () => {
|
it('restarts on "r" key press', async () => {
|
||||||
const { stdin, waitUntilReady } = await renderWithProviders(
|
const { stdin } = await renderWithProviders(
|
||||||
<AdminSettingsChangedDialog />,
|
<AdminSettingsChangedDialog />,
|
||||||
{
|
{
|
||||||
uiActions: {
|
uiActions: {
|
||||||
@@ -33,7 +32,6 @@ describe('AdminSettingsChangedDialog', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
stdin.write('r');
|
stdin.write('r');
|
||||||
@@ -43,7 +41,7 @@ describe('AdminSettingsChangedDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each(['r', 'R'])('restarts on "%s" key press', async (key) => {
|
it.each(['r', 'R'])('restarts on "%s" key press', async (key) => {
|
||||||
const { stdin, waitUntilReady } = await renderWithProviders(
|
const { stdin } = await renderWithProviders(
|
||||||
<AdminSettingsChangedDialog />,
|
<AdminSettingsChangedDialog />,
|
||||||
{
|
{
|
||||||
uiActions: {
|
uiActions: {
|
||||||
@@ -51,7 +49,6 @@ describe('AdminSettingsChangedDialog', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
stdin.write(key);
|
stdin.write(key);
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ describe('AgentConfigDialog', () => {
|
|||||||
/>,
|
/>,
|
||||||
{ settings, uiState: { mainAreaWidth: 100 } },
|
{ settings, uiState: { mainAreaWidth: 100 } },
|
||||||
);
|
);
|
||||||
await result.waitUntilReady();
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ describe('AlternateBufferQuittingDisplay', () => {
|
|||||||
|
|
||||||
it('renders with active and pending tool messages', async () => {
|
it('renders with active and pending tool messages', async () => {
|
||||||
persistentStateMock.setData({ tipsShown: 0 });
|
persistentStateMock.setData({ tipsShown: 0 });
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AlternateBufferQuittingDisplay />,
|
<AlternateBufferQuittingDisplay />,
|
||||||
{
|
{
|
||||||
uiState: {
|
uiState: {
|
||||||
@@ -118,14 +118,13 @@ describe('AlternateBufferQuittingDisplay', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot('with_history_and_pending');
|
expect(lastFrame()).toMatchSnapshot('with_history_and_pending');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with empty history and no pending items', async () => {
|
it('renders with empty history and no pending items', async () => {
|
||||||
persistentStateMock.setData({ tipsShown: 0 });
|
persistentStateMock.setData({ tipsShown: 0 });
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AlternateBufferQuittingDisplay />,
|
<AlternateBufferQuittingDisplay />,
|
||||||
{
|
{
|
||||||
uiState: {
|
uiState: {
|
||||||
@@ -135,14 +134,13 @@ describe('AlternateBufferQuittingDisplay', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot('empty');
|
expect(lastFrame()).toMatchSnapshot('empty');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with history but no pending items', async () => {
|
it('renders with history but no pending items', async () => {
|
||||||
persistentStateMock.setData({ tipsShown: 0 });
|
persistentStateMock.setData({ tipsShown: 0 });
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AlternateBufferQuittingDisplay />,
|
<AlternateBufferQuittingDisplay />,
|
||||||
{
|
{
|
||||||
uiState: {
|
uiState: {
|
||||||
@@ -152,14 +150,13 @@ describe('AlternateBufferQuittingDisplay', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot('with_history_no_pending');
|
expect(lastFrame()).toMatchSnapshot('with_history_no_pending');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with pending items but no history', async () => {
|
it('renders with pending items but no history', async () => {
|
||||||
persistentStateMock.setData({ tipsShown: 0 });
|
persistentStateMock.setData({ tipsShown: 0 });
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AlternateBufferQuittingDisplay />,
|
<AlternateBufferQuittingDisplay />,
|
||||||
{
|
{
|
||||||
uiState: {
|
uiState: {
|
||||||
@@ -169,7 +166,6 @@ describe('AlternateBufferQuittingDisplay', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot('with_pending_no_history');
|
expect(lastFrame()).toMatchSnapshot('with_pending_no_history');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -195,7 +191,7 @@ describe('AlternateBufferQuittingDisplay', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AlternateBufferQuittingDisplay />,
|
<AlternateBufferQuittingDisplay />,
|
||||||
{
|
{
|
||||||
uiState: {
|
uiState: {
|
||||||
@@ -205,7 +201,6 @@ describe('AlternateBufferQuittingDisplay', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('Action Required (was prompted):');
|
expect(output).toContain('Action Required (was prompted):');
|
||||||
expect(output).toContain('confirming_tool');
|
expect(output).toContain('confirming_tool');
|
||||||
@@ -220,7 +215,7 @@ describe('AlternateBufferQuittingDisplay', () => {
|
|||||||
{ id: 1, type: 'user', text: 'Hello Gemini' },
|
{ id: 1, type: 'user', text: 'Hello Gemini' },
|
||||||
{ id: 2, type: 'gemini', text: 'Hello User!' },
|
{ id: 2, type: 'gemini', text: 'Hello User!' },
|
||||||
];
|
];
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AlternateBufferQuittingDisplay />,
|
<AlternateBufferQuittingDisplay />,
|
||||||
{
|
{
|
||||||
uiState: {
|
uiState: {
|
||||||
@@ -230,7 +225,6 @@ describe('AlternateBufferQuittingDisplay', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');
|
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,10 +29,9 @@ describe('<AnsiOutputText />', () => {
|
|||||||
createAnsiToken({ text: 'world!' }),
|
createAnsiToken({ text: 'world!' }),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<AnsiOutputText data={data} width={80} />,
|
<AnsiOutputText data={data} width={80} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame().trim()).toBe('Hello, world!');
|
expect(lastFrame().trim()).toBe('Hello, world!');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -47,10 +46,9 @@ describe('<AnsiOutputText />', () => {
|
|||||||
{ style: { inverse: true }, text: 'Inverse' },
|
{ style: { inverse: true }, text: 'Inverse' },
|
||||||
])('correctly applies style $text', async ({ style, text }) => {
|
])('correctly applies style $text', async ({ style, text }) => {
|
||||||
const data: AnsiOutput = [[createAnsiToken({ text, ...style })]];
|
const data: AnsiOutput = [[createAnsiToken({ text, ...style })]];
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<AnsiOutputText data={data} width={80} />,
|
<AnsiOutputText data={data} width={80} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame().trim()).toBe(text);
|
expect(lastFrame().trim()).toBe(text);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -61,10 +59,9 @@ describe('<AnsiOutputText />', () => {
|
|||||||
{ color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' },
|
{ color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' },
|
||||||
])('correctly applies color $text', async ({ color, text }) => {
|
])('correctly applies color $text', async ({ color, text }) => {
|
||||||
const data: AnsiOutput = [[createAnsiToken({ text, ...color })]];
|
const data: AnsiOutput = [[createAnsiToken({ text, ...color })]];
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<AnsiOutputText data={data} width={80} />,
|
<AnsiOutputText data={data} width={80} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame().trim()).toBe(text);
|
expect(lastFrame().trim()).toBe(text);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -76,10 +73,9 @@ describe('<AnsiOutputText />', () => {
|
|||||||
[createAnsiToken({ text: 'Third line' })],
|
[createAnsiToken({ text: 'Third line' })],
|
||||||
[createAnsiToken({ text: '' })],
|
[createAnsiToken({ text: '' })],
|
||||||
];
|
];
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<AnsiOutputText data={data} width={80} />,
|
<AnsiOutputText data={data} width={80} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toBeDefined();
|
expect(output).toBeDefined();
|
||||||
const lines = output.split('\n');
|
const lines = output.split('\n');
|
||||||
@@ -96,10 +92,9 @@ describe('<AnsiOutputText />', () => {
|
|||||||
[createAnsiToken({ text: 'Line 3' })],
|
[createAnsiToken({ text: 'Line 3' })],
|
||||||
[createAnsiToken({ text: 'Line 4' })],
|
[createAnsiToken({ text: 'Line 4' })],
|
||||||
];
|
];
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<AnsiOutputText data={data} availableTerminalHeight={2} width={80} />,
|
<AnsiOutputText data={data} availableTerminalHeight={2} width={80} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).not.toContain('Line 1');
|
expect(output).not.toContain('Line 1');
|
||||||
expect(output).not.toContain('Line 2');
|
expect(output).not.toContain('Line 2');
|
||||||
@@ -115,10 +110,9 @@ describe('<AnsiOutputText />', () => {
|
|||||||
[createAnsiToken({ text: 'Line 3' })],
|
[createAnsiToken({ text: 'Line 3' })],
|
||||||
[createAnsiToken({ text: 'Line 4' })],
|
[createAnsiToken({ text: 'Line 4' })],
|
||||||
];
|
];
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<AnsiOutputText data={data} maxLines={2} width={80} />,
|
<AnsiOutputText data={data} maxLines={2} width={80} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).not.toContain('Line 1');
|
expect(output).not.toContain('Line 1');
|
||||||
expect(output).not.toContain('Line 2');
|
expect(output).not.toContain('Line 2');
|
||||||
@@ -135,7 +129,7 @@ describe('<AnsiOutputText />', () => {
|
|||||||
[createAnsiToken({ text: 'Line 4' })],
|
[createAnsiToken({ text: 'Line 4' })],
|
||||||
];
|
];
|
||||||
// availableTerminalHeight=3, maxLines=2 => show 2 lines
|
// availableTerminalHeight=3, maxLines=2 => show 2 lines
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<AnsiOutputText
|
<AnsiOutputText
|
||||||
data={data}
|
data={data}
|
||||||
availableTerminalHeight={3}
|
availableTerminalHeight={3}
|
||||||
@@ -143,7 +137,6 @@ describe('<AnsiOutputText />', () => {
|
|||||||
width={80}
|
width={80}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).not.toContain('Line 2');
|
expect(output).not.toContain('Line 2');
|
||||||
expect(output).toContain('Line 3');
|
expect(output).toContain('Line 3');
|
||||||
@@ -156,10 +149,9 @@ describe('<AnsiOutputText />', () => {
|
|||||||
for (let i = 0; i < 1000; i++) {
|
for (let i = 0; i < 1000; i++) {
|
||||||
largeData.push([createAnsiToken({ text: `Line ${i}` })]);
|
largeData.push([createAnsiToken({ text: `Line ${i}` })]);
|
||||||
}
|
}
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<AnsiOutputText data={largeData} width={80} />,
|
<AnsiOutputText data={largeData} width={80} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
// We are just checking that it renders something without crashing.
|
// We are just checking that it renders something without crashing.
|
||||||
expect(lastFrame()).toBeDefined();
|
expect(lastFrame()).toBeDefined();
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '../../test-utils/render.js';
|
} from '../../test-utils/render.js';
|
||||||
import { AppHeader } from './AppHeader.js';
|
import { AppHeader } from './AppHeader.js';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
vi.mock('../utils/terminalSetup.js', () => ({
|
vi.mock('../utils/terminalSetup.js', () => ({
|
||||||
@@ -27,13 +28,12 @@ describe('<AppHeader />', () => {
|
|||||||
bannerVisible: true,
|
bannerVisible: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AppHeader version="1.0.0" />,
|
<AppHeader version="1.0.0" />,
|
||||||
{
|
{
|
||||||
uiState,
|
uiState,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('This is the default banner');
|
expect(lastFrame()).toContain('This is the default banner');
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
@@ -50,13 +50,12 @@ describe('<AppHeader />', () => {
|
|||||||
bannerVisible: true,
|
bannerVisible: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AppHeader version="1.0.0" />,
|
<AppHeader version="1.0.0" />,
|
||||||
{
|
{
|
||||||
uiState,
|
uiState,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('There are capacity issues');
|
expect(lastFrame()).toContain('There are capacity issues');
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
@@ -72,13 +71,12 @@ describe('<AppHeader />', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AppHeader version="1.0.0" />,
|
<AppHeader version="1.0.0" />,
|
||||||
{
|
{
|
||||||
uiState,
|
uiState,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).not.toContain('Banner');
|
expect(lastFrame()).not.toContain('Banner');
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
@@ -103,13 +101,12 @@ describe('<AppHeader />', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AppHeader version="1.0.0" />,
|
<AppHeader version="1.0.0" />,
|
||||||
{
|
{
|
||||||
uiState,
|
uiState,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).not.toContain('This is the default banner');
|
expect(lastFrame()).not.toContain('This is the default banner');
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
@@ -129,13 +126,12 @@ describe('<AppHeader />', () => {
|
|||||||
// and interfering with the expected persistentState.set call.
|
// and interfering with the expected persistentState.set call.
|
||||||
persistentStateMock.setData({ tipsShown: 10 });
|
persistentStateMock.setData({ tipsShown: 10 });
|
||||||
|
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<AppHeader version="1.0.0" />,
|
<AppHeader version="1.0.0" />,
|
||||||
{
|
{
|
||||||
uiState,
|
uiState,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(persistentStateMock.set).toHaveBeenCalledWith(
|
expect(persistentStateMock.set).toHaveBeenCalledWith(
|
||||||
'defaultBannerShownCount',
|
'defaultBannerShownCount',
|
||||||
@@ -159,13 +155,12 @@ describe('<AppHeader />', () => {
|
|||||||
bannerVisible: true,
|
bannerVisible: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AppHeader version="1.0.0" />,
|
<AppHeader version="1.0.0" />,
|
||||||
{
|
{
|
||||||
uiState,
|
uiState,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).not.toContain('First line\\nSecond line');
|
expect(lastFrame()).not.toContain('First line\\nSecond line');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -183,13 +178,12 @@ describe('<AppHeader />', () => {
|
|||||||
|
|
||||||
persistentStateMock.setData({ tipsShown: 5 });
|
persistentStateMock.setData({ tipsShown: 5 });
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AppHeader version="1.0.0" />,
|
<AppHeader version="1.0.0" />,
|
||||||
{
|
{
|
||||||
uiState,
|
uiState,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Tips');
|
expect(lastFrame()).toContain('Tips');
|
||||||
expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6);
|
expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6);
|
||||||
@@ -206,13 +200,12 @@ describe('<AppHeader />', () => {
|
|||||||
|
|
||||||
persistentStateMock.setData({ tipsShown: 10 });
|
persistentStateMock.setData({ tipsShown: 10 });
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<AppHeader version="1.0.0" />,
|
<AppHeader version="1.0.0" />,
|
||||||
{
|
{
|
||||||
uiState,
|
uiState,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).not.toContain('Tips');
|
expect(lastFrame()).not.toContain('Tips');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -234,7 +227,6 @@ describe('<AppHeader />', () => {
|
|||||||
const session1 = await renderWithProviders(<AppHeader version="1.0.0" />, {
|
const session1 = await renderWithProviders(<AppHeader version="1.0.0" />, {
|
||||||
uiState,
|
uiState,
|
||||||
});
|
});
|
||||||
await session1.waitUntilReady();
|
|
||||||
|
|
||||||
expect(session1.lastFrame()).toContain('Tips');
|
expect(session1.lastFrame()).toContain('Tips');
|
||||||
expect(persistentStateMock.get('tipsShown')).toBe(10);
|
expect(persistentStateMock.get('tipsShown')).toBe(10);
|
||||||
@@ -245,9 +237,31 @@ describe('<AppHeader />', () => {
|
|||||||
<AppHeader version="1.0.0" />,
|
<AppHeader version="1.0.0" />,
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
await session2.waitUntilReady();
|
|
||||||
|
|
||||||
expect(session2.lastFrame()).not.toContain('Tips');
|
expect(session2.lastFrame()).not.toContain('Tips');
|
||||||
session2.unmount();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { CliSpinner } from './CliSpinner.js';
|
|||||||
|
|
||||||
import { isAppleTerminal } from '@google/gemini-cli-core';
|
import { isAppleTerminal } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
import { longAsciiLogoCompactText } from './AsciiArt.js';
|
||||||
|
import { getAsciiArtWidth } from '../utils/textUtils.js';
|
||||||
|
|
||||||
interface AppHeaderProps {
|
interface AppHeaderProps {
|
||||||
version: string;
|
version: string;
|
||||||
showDetails?: boolean;
|
showDetails?: boolean;
|
||||||
@@ -41,6 +44,18 @@ const MAC_TERMINAL_ICON = `▝▜▄
|
|||||||
▗▟▀
|
▗▟▀
|
||||||
▗▟▀ `;
|
▗▟▀ `;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The horizontal padding (in columns) required for metadata (version, identity, etc.)
|
||||||
|
* when rendered alongside the ASCII logo.
|
||||||
|
*/
|
||||||
|
const LOGO_METADATA_PADDING = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The terminal width below which we switch to a narrow/column layout to prevent
|
||||||
|
* UI elements from wrapping or overlapping.
|
||||||
|
*/
|
||||||
|
const NARROW_TERMINAL_BREAKPOINT = 60;
|
||||||
|
|
||||||
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
@@ -49,70 +64,90 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
|
|||||||
const { bannerText } = useBanner(bannerData);
|
const { bannerText } = useBanner(bannerData);
|
||||||
const { showTips } = useTips();
|
const { showTips } = useTips();
|
||||||
|
|
||||||
|
const authType = config.getContentGeneratorConfig()?.authType;
|
||||||
|
const loggedOut = !authType;
|
||||||
|
|
||||||
const showHeader = !(
|
const showHeader = !(
|
||||||
settings.merged.ui.hideBanner || config.getScreenReader()
|
settings.merged.ui.hideBanner || config.getScreenReader()
|
||||||
);
|
);
|
||||||
|
|
||||||
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
|
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
|
||||||
|
|
||||||
if (!showDetails) {
|
let logoTextArt = '';
|
||||||
return (
|
if (loggedOut) {
|
||||||
<Box flexDirection="column">
|
const widthOfLongLogo =
|
||||||
{showHeader && (
|
getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING;
|
||||||
<Box
|
|
||||||
flexDirection="row"
|
if (terminalWidth >= widthOfLongLogo) {
|
||||||
marginTop={1}
|
logoTextArt = longAsciiLogoCompactText.trim();
|
||||||
marginBottom={1}
|
}
|
||||||
paddingLeft={2}
|
}
|
||||||
>
|
|
||||||
<Box flexShrink={0}>
|
// If the terminal is too narrow to fit the icon and metadata (especially long nightly versions)
|
||||||
<ThemedGradient>{ICON}</ThemedGradient>
|
// side-by-side, we switch to column mode to prevent wrapping.
|
||||||
</Box>
|
const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT;
|
||||||
<Box marginLeft={2} flexDirection="column">
|
|
||||||
<Box>
|
const renderLogo = () => (
|
||||||
<Text bold color={theme.text.primary}>
|
<Box flexDirection="row">
|
||||||
Gemini CLI
|
<Box flexShrink={0}>
|
||||||
</Text>
|
<ThemedGradient>{ICON}</ThemedGradient>
|
||||||
<Text color={theme.text.secondary}> v{version}</Text>
|
</Box>
|
||||||
</Box>
|
{logoTextArt && (
|
||||||
</Box>
|
<Box marginLeft={3}>
|
||||||
|
<Text color={theme.text.primary}>{logoTextArt}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMetadata = (isBelow = false) => (
|
||||||
|
<Box marginLeft={isBelow ? 0 : 2} flexDirection="column">
|
||||||
|
{/* Line 1: Gemini CLI vVersion [Updating] */}
|
||||||
|
<Box>
|
||||||
|
<Text bold color={theme.text.primary}>
|
||||||
|
Gemini CLI
|
||||||
|
</Text>
|
||||||
|
<Text color={theme.text.secondary}> v{version}</Text>
|
||||||
|
{updateInfo && (
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
<CliSpinner /> Updating
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</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 (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
<Box flexDirection="row" marginTop={1} marginBottom={1} paddingLeft={2}>
|
<Box
|
||||||
<Box flexShrink={0}>
|
flexDirection={useColumnLayout ? 'column' : 'row'}
|
||||||
<ThemedGradient>{ICON}</ThemedGradient>
|
marginTop={1}
|
||||||
</Box>
|
marginBottom={1}
|
||||||
<Box marginLeft={2} flexDirection="column">
|
paddingLeft={1}
|
||||||
{/* Line 1: Gemini CLI vVersion [Updating] */}
|
>
|
||||||
<Box>
|
{renderLogo()}
|
||||||
<Text bold color={theme.text.primary}>
|
{useColumnLayout ? (
|
||||||
Gemini CLI
|
<Box marginTop={1}>{renderMetadata(true)}</Box>
|
||||||
</Text>
|
) : (
|
||||||
<Text color={theme.text.secondary}> v{version}</Text>
|
renderMetadata(false)
|
||||||
{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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -11,56 +11,50 @@ import { ApprovalMode } from '@google/gemini-cli-core';
|
|||||||
|
|
||||||
describe('ApprovalModeIndicator', () => {
|
describe('ApprovalModeIndicator', () => {
|
||||||
it('renders correctly for AUTO_EDIT mode', async () => {
|
it('renders correctly for AUTO_EDIT mode', async () => {
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
|
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly for AUTO_EDIT mode with plan enabled', async () => {
|
it('renders correctly for AUTO_EDIT mode with plan enabled', async () => {
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<ApprovalModeIndicator
|
<ApprovalModeIndicator
|
||||||
approvalMode={ApprovalMode.AUTO_EDIT}
|
approvalMode={ApprovalMode.AUTO_EDIT}
|
||||||
allowPlanMode={true}
|
allowPlanMode={true}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly for PLAN mode', async () => {
|
it('renders correctly for PLAN mode', async () => {
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
|
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly for YOLO mode', async () => {
|
it('renders correctly for YOLO mode', async () => {
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
|
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly for DEFAULT mode', async () => {
|
it('renders correctly for DEFAULT mode', async () => {
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
|
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly for DEFAULT mode with plan enabled', async () => {
|
it('renders correctly for DEFAULT mode with plan enabled', async () => {
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<ApprovalModeIndicator
|
<ApprovalModeIndicator
|
||||||
approvalMode={ApprovalMode.DEFAULT}
|
approvalMode={ApprovalMode.DEFAULT}
|
||||||
allowPlanMode={true}
|
allowPlanMode={true}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ export const shortAsciiLogo = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const longAsciiLogo = `
|
export const longAsciiLogo = `
|
||||||
███ █████████ ██████████ ██████ ██████ █████ ██████ █████ █████
|
█████████ ██████████ ██████ ██████ █████ ██████ █████ █████
|
||||||
░░░███ ███░░░░░███░░███░░░░░█░░██████ ██████ ░░███ ░░██████ ░░███ ░░███
|
███░░░░░███░░███░░░░░█░░██████ █████ ░░███░░██████ ░░███ ░░███
|
||||||
░░░███ ███ ░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███
|
███ ░░░░░░░ ░███ █ ░ ░███░█████░███ ░███ ░███░███ ░███ ░███
|
||||||
░░░███ ░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███
|
░███ ░██████ ░███░░███ ░███ ░███ ░███░░███░███ ░███
|
||||||
███░ ░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███
|
░███ █████ ░███░░█ ░███ ░░░ ░███ ░███ ░███ ░░██████ ░███
|
||||||
███░ ░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███
|
░░███ ░░███ ░███ ░ █ ░███ ░███ ░███ ░███ ░░█████ ░███
|
||||||
███░ ░░█████████ ██████████ █████ █████ █████ █████ ░░█████ █████
|
░░█████████ ██████████ █████ █████ █████ █████ ░░████ █████
|
||||||
░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░
|
░░░░░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const tinyAsciiLogo = `
|
export const tinyAsciiLogo = `
|
||||||
@@ -36,3 +36,24 @@ export const tinyAsciiLogo = `
|
|||||||
███░ ░░█████████
|
███░ ░░█████████
|
||||||
░░░ ░░░░░░░░░
|
░░░ ░░░░░░░░░
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const shortAsciiLogoCompactText = `
|
||||||
|
▟▛▀▀█▖▜█▀▀▜▝██▙▗██▛▝█▛▝██▙ ▜█▘▜█▘
|
||||||
|
▐█ ▐█▄▌ █▌▜█▘█▌ █▌ █▌▜▙▐█ ▐█
|
||||||
|
▝█▖ ▜█▘▐█ ▘▗ █▌ █▌ █▌ █▌ ▜██ ▐█
|
||||||
|
▝▀▀▀▀ ▀▀▀▀▀▝▀▀ ▝▀▀▝▀▀▝▀▀ ▀▀▘▀▀▘
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const longAsciiLogoCompactText = `
|
||||||
|
▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
|
||||||
|
█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
|
||||||
|
▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
|
||||||
|
▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const tinyAsciiLogoCompactText = `
|
||||||
|
▟▛▀▀█▖
|
||||||
|
▐█
|
||||||
|
▝█▖ ▜█▘
|
||||||
|
▝▀▀▀▀
|
||||||
|
`;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('AskUserDialog', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
it('renders question and options', async () => {
|
it('renders question and options', async () => {
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
const { lastFrame } = await renderWithProviders(
|
||||||
<AskUserDialog
|
<AskUserDialog
|
||||||
questions={authQuestion}
|
questions={authQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
@@ -58,7 +58,6 @@ describe('AskUserDialog', () => {
|
|||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -397,7 +396,7 @@ describe('AskUserDialog', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
const { lastFrame } = await renderWithProviders(
|
||||||
<AskUserDialog
|
<AskUserDialog
|
||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
@@ -407,12 +406,11 @@ describe('AskUserDialog', () => {
|
|||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides progress header for single question', async () => {
|
it('hides progress header for single question', async () => {
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
const { lastFrame } = await renderWithProviders(
|
||||||
<AskUserDialog
|
<AskUserDialog
|
||||||
questions={authQuestion}
|
questions={authQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
@@ -422,12 +420,11 @@ describe('AskUserDialog', () => {
|
|||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows keyboard hints', async () => {
|
it('shows keyboard hints', async () => {
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
const { lastFrame } = await renderWithProviders(
|
||||||
<AskUserDialog
|
<AskUserDialog
|
||||||
questions={authQuestion}
|
questions={authQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
@@ -437,7 +434,6 @@ describe('AskUserDialog', () => {
|
|||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -471,7 +467,6 @@ describe('AskUserDialog', () => {
|
|||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('Which testing framework?');
|
expect(lastFrame()).toContain('Which testing framework?');
|
||||||
|
|
||||||
writeKey(stdin, '\x1b[C'); // Right arrow
|
writeKey(stdin, '\x1b[C'); // Right arrow
|
||||||
@@ -582,7 +577,7 @@ describe('AskUserDialog', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
const { lastFrame } = await renderWithProviders(
|
||||||
<AskUserDialog
|
<AskUserDialog
|
||||||
questions={multiQuestions}
|
questions={multiQuestions}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
@@ -592,7 +587,6 @@ describe('AskUserDialog', () => {
|
|||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -736,7 +730,7 @@ describe('AskUserDialog', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
const { lastFrame } = await renderWithProviders(
|
||||||
<AskUserDialog
|
<AskUserDialog
|
||||||
questions={textQuestion}
|
questions={textQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
@@ -746,7 +740,6 @@ describe('AskUserDialog', () => {
|
|||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -759,7 +752,7 @@ describe('AskUserDialog', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
const { lastFrame } = await renderWithProviders(
|
||||||
<AskUserDialog
|
<AskUserDialog
|
||||||
questions={textQuestion}
|
questions={textQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
@@ -769,7 +762,6 @@ describe('AskUserDialog', () => {
|
|||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -820,7 +812,7 @@ describe('AskUserDialog', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
const { lastFrame } = await renderWithProviders(
|
||||||
<AskUserDialog
|
<AskUserDialog
|
||||||
questions={textQuestion}
|
questions={textQuestion}
|
||||||
onSubmit={vi.fn()}
|
onSubmit={vi.fn()}
|
||||||
@@ -830,7 +822,6 @@ describe('AskUserDialog', () => {
|
|||||||
{ width: 120 },
|
{ width: 120 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
|
|
||||||
it('renders the output of the active shell', async () => {
|
it('renders the output of the active shell', async () => {
|
||||||
const width = 80;
|
const width = 80;
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundShellDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
@@ -158,7 +158,6 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
@@ -166,7 +165,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
|
|
||||||
it('renders tabs for multiple shells', async () => {
|
it('renders tabs for multiple shells', async () => {
|
||||||
const width = 100;
|
const width = 100;
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundShellDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
@@ -179,7 +178,6 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
@@ -187,7 +185,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
|
|
||||||
it('highlights the focused state', async () => {
|
it('highlights the focused state', async () => {
|
||||||
const width = 80;
|
const width = 80;
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundShellDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
@@ -200,7 +198,6 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
@@ -208,7 +205,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
|
|
||||||
it('resizes the PTY on mount and when dimensions change', async () => {
|
it('resizes the PTY on mount and when dimensions change', async () => {
|
||||||
const width = 80;
|
const width = 80;
|
||||||
const { rerender, waitUntilReady, unmount } = render(
|
const { rerender, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundShellDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
@@ -221,7 +218,6 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||||
shell1.pid,
|
shell1.pid,
|
||||||
@@ -241,7 +237,6 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
/>
|
/>
|
||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||||
shell1.pid,
|
shell1.pid,
|
||||||
@@ -253,7 +248,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
|
|
||||||
it('renders the process list when isListOpenProp is true', async () => {
|
it('renders the process list when isListOpenProp is true', async () => {
|
||||||
const width = 80;
|
const width = 80;
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundShellDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
@@ -266,7 +261,6 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
@@ -274,7 +268,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
|
|
||||||
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
|
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
|
||||||
const width = 80;
|
const width = 80;
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundShellDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
@@ -287,19 +281,16 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
|
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
simulateKey({ name: 'down' });
|
simulateKey({ name: 'down' });
|
||||||
});
|
});
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
|
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
simulateKey({ name: 'l', ctrl: true });
|
simulateKey({ name: 'l', ctrl: true });
|
||||||
});
|
});
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
|
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
|
||||||
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
|
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
|
||||||
@@ -308,7 +299,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
|
|
||||||
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
|
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
|
||||||
const width = 80;
|
const width = 80;
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundShellDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
@@ -321,7 +312,6 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Initial state: shell1 (active) is highlighted
|
// Initial state: shell1 (active) is highlighted
|
||||||
|
|
||||||
@@ -329,13 +319,11 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
simulateKey({ name: 'down' });
|
simulateKey({ name: 'down' });
|
||||||
});
|
});
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Press Ctrl+K
|
// Press Ctrl+K
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
simulateKey({ name: 'k', ctrl: true });
|
simulateKey({ name: 'k', ctrl: true });
|
||||||
});
|
});
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
|
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -343,7 +331,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
|
|
||||||
it('kills the active process when Ctrl+K is pressed in output view', async () => {
|
it('kills the active process when Ctrl+K is pressed in output view', async () => {
|
||||||
const width = 80;
|
const width = 80;
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundShellDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
@@ -356,12 +344,10 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
simulateKey({ name: 'k', ctrl: true });
|
simulateKey({ name: 'k', ctrl: true });
|
||||||
});
|
});
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
|
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
|
||||||
unmount();
|
unmount();
|
||||||
@@ -370,7 +356,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
it('scrolls to active shell when list opens', async () => {
|
it('scrolls to active shell when list opens', async () => {
|
||||||
// shell2 is active
|
// shell2 is active
|
||||||
const width = 80;
|
const width = 80;
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundShellDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
@@ -383,7 +369,6 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
@@ -402,7 +387,7 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
mockShells.set(exitedShell.pid, exitedShell);
|
mockShells.set(exitedShell.pid, exitedShell);
|
||||||
|
|
||||||
const width = 80;
|
const width = 80;
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
<BackgroundShellDisplay
|
<BackgroundShellDisplay
|
||||||
shells={mockShells}
|
shells={mockShells}
|
||||||
@@ -415,7 +400,6 @@ describe('<BackgroundShellDisplay />', () => {
|
|||||||
</ScrollProvider>,
|
</ScrollProvider>,
|
||||||
width,
|
width,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ describe('<Checklist />', () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
it('renders nothing when list is empty', async () => {
|
it('renders nothing when list is empty', async () => {
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<Checklist title="Test List" items={[]} isExpanded={true} />,
|
<Checklist title="Test List" items={[]} isExpanded={true} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,15 +29,14 @@ describe('<Checklist />', () => {
|
|||||||
{ status: 'completed', label: 'Task 1' },
|
{ status: 'completed', label: 'Task 1' },
|
||||||
{ status: 'cancelled', label: 'Task 2' },
|
{ status: 'cancelled', label: 'Task 2' },
|
||||||
];
|
];
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
|
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders summary view correctly (collapsed)', async () => {
|
it('renders summary view correctly (collapsed)', async () => {
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<Checklist
|
<Checklist
|
||||||
title="Test List"
|
title="Test List"
|
||||||
items={items}
|
items={items}
|
||||||
@@ -46,12 +44,11 @@ describe('<Checklist />', () => {
|
|||||||
toggleHint="toggle me"
|
toggleHint="toggle me"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders expanded view correctly', async () => {
|
it('renders expanded view correctly', async () => {
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<Checklist
|
<Checklist
|
||||||
title="Test List"
|
title="Test List"
|
||||||
items={items}
|
items={items}
|
||||||
@@ -59,7 +56,6 @@ describe('<Checklist />', () => {
|
|||||||
toggleHint="toggle me"
|
toggleHint="toggle me"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,10 +64,9 @@ describe('<Checklist />', () => {
|
|||||||
{ status: 'completed', label: 'Task 1' },
|
{ status: 'completed', label: 'Task 1' },
|
||||||
{ status: 'pending', label: 'Task 2' },
|
{ status: 'pending', label: 'Task 2' },
|
||||||
];
|
];
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
|
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ describe('<ChecklistItem />', () => {
|
|||||||
{ status: 'cancelled', label: 'Skipped this' },
|
{ status: 'cancelled', label: 'Skipped this' },
|
||||||
{ status: 'blocked', label: 'Blocked this' },
|
{ status: 'blocked', label: 'Blocked this' },
|
||||||
] as ChecklistItemData[])('renders %s item correctly', async (item) => {
|
] as ChecklistItemData[])('renders %s item correctly', async (item) => {
|
||||||
const { lastFrame, waitUntilReady } = render(<ChecklistItem item={item} />);
|
const { lastFrame } = await render(<ChecklistItem item={item} />);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,12 +27,11 @@ describe('<ChecklistItem />', () => {
|
|||||||
label:
|
label:
|
||||||
'This is a very long text that should be truncated because the wrap prop is set to truncate',
|
'This is a very long text that should be truncated because the wrap prop is set to truncate',
|
||||||
};
|
};
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<Box width={30}>
|
<Box width={30}>
|
||||||
<ChecklistItem item={item} wrap="truncate" />
|
<ChecklistItem item={item} wrap="truncate" />
|
||||||
</Box>,
|
</Box>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,12 +41,11 @@ describe('<ChecklistItem />', () => {
|
|||||||
label:
|
label:
|
||||||
'This is a very long text that should wrap because the default behavior is wrapping',
|
'This is a very long text that should wrap because the default behavior is wrapping',
|
||||||
};
|
};
|
||||||
const { lastFrame, waitUntilReady } = render(
|
const { lastFrame } = await render(
|
||||||
<Box width={30}>
|
<Box width={30}>
|
||||||
<ChecklistItem item={item} />
|
<ChecklistItem item={item} />
|
||||||
</Box>,
|
</Box>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,10 +17,7 @@ describe('<CliSpinner />', () => {
|
|||||||
|
|
||||||
it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => {
|
it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => {
|
||||||
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(<CliSpinner />);
|
||||||
<CliSpinner />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(debugState.debugNumAnimatedComponents).toBe(1);
|
expect(debugState.debugNumAnimatedComponents).toBe(1);
|
||||||
unmount();
|
unmount();
|
||||||
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
expect(debugState.debugNumAnimatedComponents).toBe(0);
|
||||||
@@ -28,11 +25,9 @@ describe('<CliSpinner />', () => {
|
|||||||
|
|
||||||
it('should not render when showSpinner is false', async () => {
|
it('should not render when showSpinner is false', async () => {
|
||||||
const settings = createMockSettings({ ui: { showSpinner: false } });
|
const settings = createMockSettings({ ui: { showSpinner: false } });
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<CliSpinner />, {
|
||||||
<CliSpinner />,
|
settings,
|
||||||
{ settings },
|
});
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -96,10 +96,9 @@ describe('ColorsDisplay', () => {
|
|||||||
|
|
||||||
it('renders correctly', async () => {
|
it('renders correctly', async () => {
|
||||||
const mockTheme = themeManager.getActiveTheme();
|
const mockTheme = themeManager.getActiveTheme();
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<ColorsDisplay activeTheme={mockTheme} />,
|
<ColorsDisplay activeTheme={mockTheme} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|
||||||
// Check for title and description
|
// Check for title and description
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ const renderComposer = async (
|
|||||||
config = createMockConfig(),
|
config = createMockConfig(),
|
||||||
uiActions = createMockUIActions(),
|
uiActions = createMockUIActions(),
|
||||||
) => {
|
) => {
|
||||||
const result = render(
|
const result = await render(
|
||||||
<ConfigContext.Provider value={config as unknown as Config}>
|
<ConfigContext.Provider value={config as unknown as Config}>
|
||||||
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
|
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
|
||||||
<UIStateContext.Provider value={uiState}>
|
<UIStateContext.Provider value={uiState}>
|
||||||
@@ -262,7 +262,6 @@ const renderComposer = async (
|
|||||||
</SettingsContext.Provider>
|
</SettingsContext.Provider>
|
||||||
</ConfigContext.Provider>,
|
</ConfigContext.Provider>,
|
||||||
);
|
);
|
||||||
await result.waitUntilReady();
|
|
||||||
|
|
||||||
// Wait for shortcuts hint debounce if using fake timers
|
// Wait for shortcuts hint debounce if using fake timers
|
||||||
if (vi.isFakeTimers()) {
|
if (vi.isFakeTimers()) {
|
||||||
|
|||||||
@@ -43,10 +43,7 @@ describe('ConfigInitDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders initial state', async () => {
|
it('renders initial state', async () => {
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
const { lastFrame } = await renderWithProviders(<ConfigInitDisplay />);
|
||||||
<ConfigInitDisplay />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,14 +33,13 @@ describe('ConsentPrompt', () => {
|
|||||||
|
|
||||||
it('renders a string prompt with MarkdownDisplay', async () => {
|
it('renders a string prompt with MarkdownDisplay', async () => {
|
||||||
const prompt = 'Are you sure?';
|
const prompt = 'Are you sure?';
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(
|
||||||
<ConsentPrompt
|
<ConsentPrompt
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
|
expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
@@ -55,14 +54,13 @@ describe('ConsentPrompt', () => {
|
|||||||
|
|
||||||
it('renders a ReactNode prompt directly', async () => {
|
it('renders a ReactNode prompt directly', async () => {
|
||||||
const prompt = <Text>Are you sure?</Text>;
|
const prompt = <Text>Are you sure?</Text>;
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ConsentPrompt
|
<ConsentPrompt
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(MockedMarkdownDisplay).not.toHaveBeenCalled();
|
expect(MockedMarkdownDisplay).not.toHaveBeenCalled();
|
||||||
expect(lastFrame()).toContain('Are you sure?');
|
expect(lastFrame()).toContain('Are you sure?');
|
||||||
@@ -71,14 +69,13 @@ describe('ConsentPrompt', () => {
|
|||||||
|
|
||||||
it('calls onConfirm with true when "Yes" is selected', async () => {
|
it('calls onConfirm with true when "Yes" is selected', async () => {
|
||||||
const prompt = 'Are you sure?';
|
const prompt = 'Are you sure?';
|
||||||
const { waitUntilReady, unmount } = render(
|
const { waitUntilReady, unmount } = await render(
|
||||||
<ConsentPrompt
|
<ConsentPrompt
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
|
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -92,14 +89,13 @@ describe('ConsentPrompt', () => {
|
|||||||
|
|
||||||
it('calls onConfirm with false when "No" is selected', async () => {
|
it('calls onConfirm with false when "No" is selected', async () => {
|
||||||
const prompt = 'Are you sure?';
|
const prompt = 'Are you sure?';
|
||||||
const { waitUntilReady, unmount } = render(
|
const { waitUntilReady, unmount } = await render(
|
||||||
<ConsentPrompt
|
<ConsentPrompt
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
|
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -113,14 +109,13 @@ describe('ConsentPrompt', () => {
|
|||||||
|
|
||||||
it('passes correct items to RadioButtonSelect', async () => {
|
it('passes correct items to RadioButtonSelect', async () => {
|
||||||
const prompt = 'Are you sure?';
|
const prompt = 'Are you sure?';
|
||||||
const { waitUntilReady, unmount } = render(
|
const { unmount } = await render(
|
||||||
<ConsentPrompt
|
<ConsentPrompt
|
||||||
prompt={prompt}
|
prompt={prompt}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
terminalWidth={terminalWidth}
|
terminalWidth={terminalWidth}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(MockedRadioButtonSelect).toHaveBeenCalledWith(
|
expect(MockedRadioButtonSelect).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import { describe, it, expect } from 'vitest';
|
|||||||
|
|
||||||
describe('ConsoleSummaryDisplay', () => {
|
describe('ConsoleSummaryDisplay', () => {
|
||||||
it('renders nothing when errorCount is 0', async () => {
|
it('renders nothing when errorCount is 0', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ConsoleSummaryDisplay errorCount={0} />,
|
<ConsoleSummaryDisplay errorCount={0} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -22,10 +21,9 @@ describe('ConsoleSummaryDisplay', () => {
|
|||||||
[1, '1 error'],
|
[1, '1 error'],
|
||||||
[5, '5 errors'],
|
[5, '5 errors'],
|
||||||
])('renders correct message for %i errors', async (count, expectedText) => {
|
])('renders correct message for %i errors', async (count, expectedText) => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<ConsoleSummaryDisplay errorCount={count} />,
|
<ConsoleSummaryDisplay errorCount={count} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain(expectedText);
|
expect(output).toContain(expectedText);
|
||||||
expect(output).toContain('✖');
|
expect(output).toContain('✖');
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ const renderWithWidth = async (
|
|||||||
props: React.ComponentProps<typeof ContextSummaryDisplay>,
|
props: React.ComponentProps<typeof ContextSummaryDisplay>,
|
||||||
) => {
|
) => {
|
||||||
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
|
||||||
const result = render(<ContextSummaryDisplay {...props} />);
|
const result = await render(<ContextSummaryDisplay {...props} />);
|
||||||
await result.waitUntilReady();
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,35 +19,33 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
|||||||
|
|
||||||
describe('ContextUsageDisplay', () => {
|
describe('ContextUsageDisplay', () => {
|
||||||
it('renders correct percentage used', async () => {
|
it('renders correct percentage used', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<ContextUsageDisplay
|
<ContextUsageDisplay
|
||||||
promptTokenCount={5000}
|
promptTokenCount={5000}
|
||||||
model="gemini-pro"
|
model="gemini-pro"
|
||||||
terminalWidth={120}
|
terminalWidth={120}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('50% used');
|
expect(output).toContain('50% used');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly when usage is 0%', async () => {
|
it('renders correctly when usage is 0%', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<ContextUsageDisplay
|
<ContextUsageDisplay
|
||||||
promptTokenCount={0}
|
promptTokenCount={0}
|
||||||
model="gemini-pro"
|
model="gemini-pro"
|
||||||
terminalWidth={120}
|
terminalWidth={120}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('0% used');
|
expect(output).toContain('0% used');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders abbreviated label when terminal width is small', async () => {
|
it('renders abbreviated label when terminal width is small', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<ContextUsageDisplay
|
<ContextUsageDisplay
|
||||||
promptTokenCount={2000}
|
promptTokenCount={2000}
|
||||||
model="gemini-pro"
|
model="gemini-pro"
|
||||||
@@ -55,7 +53,6 @@ describe('ContextUsageDisplay', () => {
|
|||||||
/>,
|
/>,
|
||||||
{ width: 80 },
|
{ width: 80 },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('20%');
|
expect(output).toContain('20%');
|
||||||
expect(output).not.toContain('context used');
|
expect(output).not.toContain('context used');
|
||||||
@@ -63,28 +60,26 @@ describe('ContextUsageDisplay', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders 80% correctly', async () => {
|
it('renders 80% correctly', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<ContextUsageDisplay
|
<ContextUsageDisplay
|
||||||
promptTokenCount={8000}
|
promptTokenCount={8000}
|
||||||
model="gemini-pro"
|
model="gemini-pro"
|
||||||
terminalWidth={120}
|
terminalWidth={120}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('80% used');
|
expect(output).toContain('80% used');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders 100% when full', async () => {
|
it('renders 100% when full', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<ContextUsageDisplay
|
<ContextUsageDisplay
|
||||||
promptTokenCount={10000}
|
promptTokenCount={10000}
|
||||||
model="gemini-pro"
|
model="gemini-pro"
|
||||||
terminalWidth={120}
|
terminalWidth={120}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toContain('100% used');
|
expect(output).toContain('100% used');
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ describe('CopyModeWarning', () => {
|
|||||||
mockUseUIState.mockReturnValue({
|
mockUseUIState.mockReturnValue({
|
||||||
copyModeEnabled: false,
|
copyModeEnabled: false,
|
||||||
} as unknown as UIState);
|
} as unknown as UIState);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
|
const { lastFrame, unmount } = await render(<CopyModeWarning />);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -32,8 +31,7 @@ describe('CopyModeWarning', () => {
|
|||||||
mockUseUIState.mockReturnValue({
|
mockUseUIState.mockReturnValue({
|
||||||
copyModeEnabled: true,
|
copyModeEnabled: true,
|
||||||
} as unknown as UIState);
|
} as unknown as UIState);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
|
const { lastFrame, unmount } = await render(<CopyModeWarning />);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('In Copy Mode');
|
expect(lastFrame()).toContain('In Copy Mode');
|
||||||
expect(lastFrame()).toContain('Use Page Up/Down to scroll');
|
expect(lastFrame()).toContain('Use Page Up/Down to scroll');
|
||||||
expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit');
|
expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit');
|
||||||
|
|||||||
@@ -242,8 +242,7 @@ describe('DebugProfiler Component', () => {
|
|||||||
showDebugProfiler: false,
|
showDebugProfiler: false,
|
||||||
constrainHeight: false,
|
constrainHeight: false,
|
||||||
} as unknown as UIState);
|
} as unknown as UIState);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(<DebugProfiler />);
|
const { lastFrame, unmount } = await render(<DebugProfiler />);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -257,8 +256,7 @@ describe('DebugProfiler Component', () => {
|
|||||||
profiler.totalIdleFrames = 5;
|
profiler.totalIdleFrames = 5;
|
||||||
profiler.totalFlickerFrames = 2;
|
profiler.totalFlickerFrames = 2;
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(<DebugProfiler />);
|
const { lastFrame, unmount } = await render(<DebugProfiler />);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|
||||||
expect(output).toContain('Renders: 10 (total)');
|
expect(output).toContain('Renders: 10 (total)');
|
||||||
@@ -275,8 +273,7 @@ describe('DebugProfiler Component', () => {
|
|||||||
|
|
||||||
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
|
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
|
||||||
|
|
||||||
const { waitUntilReady, unmount } = render(<DebugProfiler />);
|
const { waitUntilReady, unmount } = await render(<DebugProfiler />);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
coreEvents.emitModelChanged('new-model');
|
coreEvents.emitModelChanged('new-model');
|
||||||
@@ -295,8 +292,7 @@ describe('DebugProfiler Component', () => {
|
|||||||
|
|
||||||
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
|
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
|
||||||
|
|
||||||
const { waitUntilReady, unmount } = render(<DebugProfiler />);
|
const { waitUntilReady, unmount } = await render(<DebugProfiler />);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
appEvents.emit(AppEvent.SelectionWarning);
|
appEvents.emit(AppEvent.SelectionWarning);
|
||||||
|
|||||||
@@ -41,13 +41,12 @@ describe('DetailedMessagesDisplay', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('renders nothing when messages are empty', async () => {
|
it('renders nothing when messages are empty', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
|
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
|
||||||
{
|
{
|
||||||
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -64,13 +63,12 @@ describe('DetailedMessagesDisplay', () => {
|
|||||||
clearConsoleMessages: vi.fn(),
|
clearConsoleMessages: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||||
{
|
{
|
||||||
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|
||||||
expect(output).toMatchSnapshot();
|
expect(output).toMatchSnapshot();
|
||||||
@@ -86,13 +84,12 @@ describe('DetailedMessagesDisplay', () => {
|
|||||||
clearConsoleMessages: vi.fn(),
|
clearConsoleMessages: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||||
{
|
{
|
||||||
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
|
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('(F12 to close)');
|
expect(lastFrame()).toContain('(F12 to close)');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -106,13 +103,12 @@ describe('DetailedMessagesDisplay', () => {
|
|||||||
clearConsoleMessages: vi.fn(),
|
clearConsoleMessages: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
|
||||||
{
|
{
|
||||||
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('(F12 to close)');
|
expect(lastFrame()).toContain('(F12 to close)');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -126,13 +122,12 @@ describe('DetailedMessagesDisplay', () => {
|
|||||||
clearConsoleMessages: vi.fn(),
|
clearConsoleMessages: vi.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
|
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
|
||||||
{
|
{
|
||||||
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|
||||||
expect(output).toMatchSnapshot();
|
expect(output).toMatchSnapshot();
|
||||||
|
|||||||
@@ -104,11 +104,10 @@ describe('DialogManager', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('renders nothing by default', async () => {
|
it('renders nothing by default', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<DialogManager {...defaultProps} />,
|
<DialogManager {...defaultProps} />,
|
||||||
{ uiState: baseUiState as Partial<UIState> as UIState },
|
{ uiState: baseUiState as Partial<UIState> as UIState },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -197,7 +196,7 @@ describe('DialogManager', () => {
|
|||||||
it.each(testCases)(
|
it.each(testCases)(
|
||||||
'renders %s when state is %o',
|
'renders %s when state is %o',
|
||||||
async (uiStateOverride, expectedComponent) => {
|
async (uiStateOverride, expectedComponent) => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<DialogManager {...defaultProps} />,
|
<DialogManager {...defaultProps} />,
|
||||||
{
|
{
|
||||||
uiState: {
|
uiState: {
|
||||||
@@ -206,7 +205,6 @@ describe('DialogManager', () => {
|
|||||||
} as Partial<UIState> as UIState,
|
} as Partial<UIState> as UIState,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain(expectedComponent);
|
expect(lastFrame()).toContain(expectedComponent);
|
||||||
unmount();
|
unmount();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,27 +55,25 @@ describe('EditorSettingsDialog', () => {
|
|||||||
renderWithProviders(ui);
|
renderWithProviders(ui);
|
||||||
|
|
||||||
it('renders correctly', async () => {
|
it('renders correctly', async () => {
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProvider(
|
const { lastFrame } = await renderWithProvider(
|
||||||
<EditorSettingsDialog
|
<EditorSettingsDialog
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
settings={mockSettings}
|
settings={mockSettings}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onSelect when an editor is selected', async () => {
|
it('calls onSelect when an editor is selected', async () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProvider(
|
const { lastFrame } = await renderWithProvider(
|
||||||
<EditorSettingsDialog
|
<EditorSettingsDialog
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
settings={mockSettings}
|
settings={mockSettings}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('VS Code');
|
expect(lastFrame()).toContain('VS Code');
|
||||||
});
|
});
|
||||||
@@ -88,7 +86,6 @@ describe('EditorSettingsDialog', () => {
|
|||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Initial focus on editor
|
// Initial focus on editor
|
||||||
expect(lastFrame()).toContain('> Select Editor');
|
expect(lastFrame()).toContain('> Select Editor');
|
||||||
@@ -134,7 +131,6 @@ describe('EditorSettingsDialog', () => {
|
|||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
stdin.write('\u001B'); // Escape
|
stdin.write('\u001B'); // Escape
|
||||||
@@ -162,14 +158,13 @@ describe('EditorSettingsDialog', () => {
|
|||||||
},
|
},
|
||||||
} as unknown as LoadedSettings;
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
const { lastFrame, waitUntilReady } = await renderWithProvider(
|
const { lastFrame } = await renderWithProvider(
|
||||||
<EditorSettingsDialog
|
<EditorSettingsDialog
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
settings={settingsWithOtherScope}
|
settings={settingsWithOtherScope}
|
||||||
onExit={vi.fn()}
|
onExit={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
const frame = lastFrame() || '';
|
const frame = lastFrame() || '';
|
||||||
if (!frame.includes('(Also modified')) {
|
if (!frame.includes('(Also modified')) {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe('EmptyWalletDialog', () => {
|
|||||||
|
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('should match snapshot with fallback available', async () => {
|
it('should match snapshot with fallback available', async () => {
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
fallbackModel="gemini-3-flash-preview"
|
fallbackModel="gemini-3-flash-preview"
|
||||||
@@ -38,33 +38,30 @@ describe('EmptyWalletDialog', () => {
|
|||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match snapshot without fallback', async () => {
|
it('should match snapshot without fallback', async () => {
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display the model name and usage limit message', async () => {
|
it('should display the model name and usage limit message', async () => {
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
const output = lastFrame() ?? '';
|
const output = lastFrame() ?? '';
|
||||||
expect(output).toContain('gemini-2.5-pro');
|
expect(output).toContain('gemini-2.5-pro');
|
||||||
@@ -73,13 +70,12 @@ describe('EmptyWalletDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display purchase prompt and credits update notice', async () => {
|
it('should display purchase prompt and credits update notice', async () => {
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
const output = lastFrame() ?? '';
|
const output = lastFrame() ?? '';
|
||||||
expect(output).toContain('purchase more AI Credits');
|
expect(output).toContain('purchase more AI Credits');
|
||||||
@@ -90,14 +86,13 @@ describe('EmptyWalletDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display reset time when provided', async () => {
|
it('should display reset time when provided', async () => {
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
resetTime="3:45 PM"
|
resetTime="3:45 PM"
|
||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
const output = lastFrame() ?? '';
|
const output = lastFrame() ?? '';
|
||||||
expect(output).toContain('3:45 PM');
|
expect(output).toContain('3:45 PM');
|
||||||
@@ -106,13 +101,12 @@ describe('EmptyWalletDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not display reset time when not provided', async () => {
|
it('should not display reset time when not provided', async () => {
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
const output = lastFrame() ?? '';
|
const output = lastFrame() ?? '';
|
||||||
expect(output).not.toContain('Access resets at');
|
expect(output).not.toContain('Access resets at');
|
||||||
@@ -120,13 +114,12 @@ describe('EmptyWalletDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display slash command hints', async () => {
|
it('should display slash command hints', async () => {
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
const output = lastFrame() ?? '';
|
const output = lastFrame() ?? '';
|
||||||
expect(output).toContain('/stats');
|
expect(output).toContain('/stats');
|
||||||
@@ -139,14 +132,13 @@ describe('EmptyWalletDialog', () => {
|
|||||||
describe('onChoice handling', () => {
|
describe('onChoice handling', () => {
|
||||||
it('should call onGetCredits and onChoice when get_credits is selected', async () => {
|
it('should call onGetCredits and onChoice when get_credits is selected', async () => {
|
||||||
// get_credits is the first item, so just press Enter
|
// get_credits is the first item, so just press Enter
|
||||||
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
|
const { unmount, stdin } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
onGetCredits={mockOnGetCredits}
|
onGetCredits={mockOnGetCredits}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
writeKey(stdin, '\r');
|
writeKey(stdin, '\r');
|
||||||
|
|
||||||
@@ -158,13 +150,12 @@ describe('EmptyWalletDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => {
|
it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => {
|
||||||
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
|
const { unmount, stdin } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
writeKey(stdin, '\r');
|
writeKey(stdin, '\r');
|
||||||
|
|
||||||
@@ -177,14 +168,13 @@ describe('EmptyWalletDialog', () => {
|
|||||||
it('should call onChoice with use_fallback when selected', async () => {
|
it('should call onChoice with use_fallback when selected', async () => {
|
||||||
// With fallback: items are [get_credits, use_fallback, stop]
|
// With fallback: items are [get_credits, use_fallback, stop]
|
||||||
// use_fallback is the second item: Down + Enter
|
// use_fallback is the second item: Down + Enter
|
||||||
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
|
const { unmount, stdin } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
fallbackModel="gemini-3-flash-preview"
|
fallbackModel="gemini-3-flash-preview"
|
||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||||
writeKey(stdin, '\r');
|
writeKey(stdin, '\r');
|
||||||
@@ -198,13 +188,12 @@ describe('EmptyWalletDialog', () => {
|
|||||||
it('should call onChoice with stop when selected', async () => {
|
it('should call onChoice with stop when selected', async () => {
|
||||||
// Without fallback: items are [get_credits, stop]
|
// Without fallback: items are [get_credits, stop]
|
||||||
// stop is the second item: Down + Enter
|
// stop is the second item: Down + Enter
|
||||||
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
|
const { unmount, stdin } = await renderWithProviders(
|
||||||
<EmptyWalletDialog
|
<EmptyWalletDialog
|
||||||
failedModel="gemini-2.5-pro"
|
failedModel="gemini-2.5-pro"
|
||||||
onChoice={mockOnChoice}
|
onChoice={mockOnChoice}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||||
writeKey(stdin, '\r');
|
writeKey(stdin, '\r');
|
||||||
|
|||||||
@@ -440,36 +440,38 @@ Implement a comprehensive authentication system with multiple providers.
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { stdin, lastFrame } = await renderWithProviders(
|
const { stdin, lastFrame } = await act(async () =>
|
||||||
<BubbleListener>
|
renderWithProviders(
|
||||||
<ExitPlanModeDialog
|
<BubbleListener>
|
||||||
planPath={mockPlanFullPath}
|
<ExitPlanModeDialog
|
||||||
onApprove={onApprove}
|
planPath={mockPlanFullPath}
|
||||||
onFeedback={onFeedback}
|
onApprove={onApprove}
|
||||||
onCancel={onCancel}
|
onFeedback={onFeedback}
|
||||||
getPreferredEditor={vi.fn()}
|
onCancel={onCancel}
|
||||||
width={80}
|
getPreferredEditor={vi.fn()}
|
||||||
availableHeight={24}
|
width={80}
|
||||||
/>
|
availableHeight={24}
|
||||||
</BubbleListener>,
|
/>
|
||||||
{
|
</BubbleListener>,
|
||||||
config: {
|
{
|
||||||
getTargetDir: () => mockTargetDir,
|
config: {
|
||||||
getIdeMode: () => false,
|
getTargetDir: () => mockTargetDir,
|
||||||
isTrustedFolder: () => true,
|
getIdeMode: () => false,
|
||||||
storage: {
|
isTrustedFolder: () => true,
|
||||||
getPlansDir: () => mockPlansDir,
|
storage: {
|
||||||
},
|
getPlansDir: () => mockPlansDir,
|
||||||
getFileSystemService: (): FileSystemService => ({
|
},
|
||||||
readTextFile: vi.fn(),
|
getFileSystemService: (): FileSystemService => ({
|
||||||
writeTextFile: vi.fn(),
|
readTextFile: vi.fn(),
|
||||||
|
writeTextFile: vi.fn(),
|
||||||
|
}),
|
||||||
|
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
|
||||||
|
} as unknown as import('@google/gemini-cli-core').Config,
|
||||||
|
settings: createMockSettings({
|
||||||
|
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
|
||||||
}),
|
}),
|
||||||
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
|
},
|
||||||
} as unknown as import('@google/gemini-cli-core').Config,
|
),
|
||||||
settings: createMockSettings({
|
|
||||||
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ describe('ExitWarning', () => {
|
|||||||
ctrlCPressedOnce: false,
|
ctrlCPressedOnce: false,
|
||||||
ctrlDPressedOnce: false,
|
ctrlDPressedOnce: false,
|
||||||
} as unknown as UIState);
|
} as unknown as UIState);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
|
const { lastFrame, unmount } = await render(<ExitWarning />);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -36,8 +35,7 @@ describe('ExitWarning', () => {
|
|||||||
ctrlCPressedOnce: true,
|
ctrlCPressedOnce: true,
|
||||||
ctrlDPressedOnce: false,
|
ctrlDPressedOnce: false,
|
||||||
} as unknown as UIState);
|
} as unknown as UIState);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
|
const { lastFrame, unmount } = await render(<ExitWarning />);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
|
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -48,8 +46,7 @@ describe('ExitWarning', () => {
|
|||||||
ctrlCPressedOnce: false,
|
ctrlCPressedOnce: false,
|
||||||
ctrlDPressedOnce: true,
|
ctrlDPressedOnce: true,
|
||||||
} as unknown as UIState);
|
} as unknown as UIState);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
|
const { lastFrame, unmount } = await render(<ExitWarning />);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
|
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -60,8 +57,7 @@ describe('ExitWarning', () => {
|
|||||||
ctrlCPressedOnce: true,
|
ctrlCPressedOnce: true,
|
||||||
ctrlDPressedOnce: true,
|
ctrlDPressedOnce: true,
|
||||||
} as unknown as UIState);
|
} as unknown as UIState);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
|
const { lastFrame, unmount } = await render(<ExitWarning />);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,10 +48,9 @@ describe('FolderTrustDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render the dialog with title and description', async () => {
|
it('should render the dialog with title and description', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
||||||
expect(lastFrame()).toContain(
|
expect(lastFrame()).toContain(
|
||||||
@@ -72,7 +71,7 @@ describe('FolderTrustDialog', () => {
|
|||||||
discoveryErrors: [],
|
discoveryErrors: [],
|
||||||
securityWarnings: [],
|
securityWarnings: [],
|
||||||
};
|
};
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog
|
<FolderTrustDialog
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
discoveryResults={discoveryResults}
|
discoveryResults={discoveryResults}
|
||||||
@@ -85,7 +84,6 @@ describe('FolderTrustDialog', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('This folder contains:');
|
expect(lastFrame()).toContain('This folder contains:');
|
||||||
expect(lastFrame()).toContain('hidden');
|
expect(lastFrame()).toContain('hidden');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -103,7 +101,7 @@ describe('FolderTrustDialog', () => {
|
|||||||
discoveryErrors: [],
|
discoveryErrors: [],
|
||||||
securityWarnings: [],
|
securityWarnings: [],
|
||||||
};
|
};
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog
|
<FolderTrustDialog
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
discoveryResults={discoveryResults}
|
discoveryResults={discoveryResults}
|
||||||
@@ -116,7 +114,6 @@ describe('FolderTrustDialog', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
// With maxHeight=4, the intro text (4 lines) will take most of the space.
|
// With maxHeight=4, the intro text (4 lines) will take most of the space.
|
||||||
// The discovery results will likely be hidden.
|
// The discovery results will likely be hidden.
|
||||||
expect(lastFrame()).toContain('hidden');
|
expect(lastFrame()).toContain('hidden');
|
||||||
@@ -135,7 +132,7 @@ describe('FolderTrustDialog', () => {
|
|||||||
discoveryErrors: [],
|
discoveryErrors: [],
|
||||||
securityWarnings: [],
|
securityWarnings: [],
|
||||||
};
|
};
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog
|
<FolderTrustDialog
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
discoveryResults={discoveryResults}
|
discoveryResults={discoveryResults}
|
||||||
@@ -148,7 +145,6 @@ describe('FolderTrustDialog', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('hidden');
|
expect(lastFrame()).toContain('hidden');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -182,9 +178,7 @@ describe('FolderTrustDialog', () => {
|
|||||||
// Initial state: truncated
|
// Initial state: truncated
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
||||||
// In standard terminal mode, the expansion hint is handled globally by ToastDisplay
|
expect(lastFrame()).toContain('Press Ctrl+O');
|
||||||
// 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');
|
expect(lastFrame()).toContain('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -221,7 +215,6 @@ describe('FolderTrustDialog', () => {
|
|||||||
await renderWithProviders(
|
await renderWithProviders(
|
||||||
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
|
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
stdin.write('\u001b[27u'); // Press kitty escape key
|
stdin.write('\u001b[27u'); // Press kitty escape key
|
||||||
@@ -246,10 +239,9 @@ describe('FolderTrustDialog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display restart message when isRestarting is true', async () => {
|
it('should display restart message when isRestarting is true', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('Gemini CLI is restarting');
|
expect(lastFrame()).toContain('Gemini CLI is restarting');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -260,10 +252,9 @@ describe('FolderTrustDialog', () => {
|
|||||||
const relaunchApp = vi
|
const relaunchApp = vi
|
||||||
.spyOn(processUtils, 'relaunchApp')
|
.spyOn(processUtils, 'relaunchApp')
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
await vi.advanceTimersByTimeAsync(250);
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
expect(relaunchApp).toHaveBeenCalled();
|
expect(relaunchApp).toHaveBeenCalled();
|
||||||
unmount();
|
unmount();
|
||||||
@@ -275,10 +266,9 @@ describe('FolderTrustDialog', () => {
|
|||||||
const relaunchApp = vi
|
const relaunchApp = vi
|
||||||
.spyOn(processUtils, 'relaunchApp')
|
.spyOn(processUtils, 'relaunchApp')
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
const { waitUntilReady, unmount } = await renderWithProviders(
|
const { unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Unmount immediately (before 250ms)
|
// Unmount immediately (before 250ms)
|
||||||
unmount();
|
unmount();
|
||||||
@@ -292,7 +282,6 @@ describe('FolderTrustDialog', () => {
|
|||||||
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
|
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
|
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
stdin.write('r');
|
stdin.write('r');
|
||||||
@@ -308,30 +297,27 @@ describe('FolderTrustDialog', () => {
|
|||||||
describe('directory display', () => {
|
describe('directory display', () => {
|
||||||
it('should correctly display the folder name for a nested directory', async () => {
|
it('should correctly display the folder name for a nested directory', async () => {
|
||||||
mockedCwd.mockReturnValue('/home/user/project');
|
mockedCwd.mockReturnValue('/home/user/project');
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('Trust folder (project)');
|
expect(lastFrame()).toContain('Trust folder (project)');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly display the parent folder name for a nested directory', async () => {
|
it('should correctly display the parent folder name for a nested directory', async () => {
|
||||||
mockedCwd.mockReturnValue('/home/user/project');
|
mockedCwd.mockReturnValue('/home/user/project');
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('Trust parent folder (user)');
|
expect(lastFrame()).toContain('Trust parent folder (user)');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly display an empty parent folder name for a directory directly under root', async () => {
|
it('should correctly display an empty parent folder name for a directory directly under root', async () => {
|
||||||
mockedCwd.mockReturnValue('/project');
|
mockedCwd.mockReturnValue('/project');
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog onSelect={vi.fn()} />,
|
<FolderTrustDialog onSelect={vi.fn()} />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('Trust parent folder ()');
|
expect(lastFrame()).toContain('Trust parent folder ()');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -348,7 +334,7 @@ describe('FolderTrustDialog', () => {
|
|||||||
discoveryErrors: [],
|
discoveryErrors: [],
|
||||||
securityWarnings: [],
|
securityWarnings: [],
|
||||||
};
|
};
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog
|
<FolderTrustDialog
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
discoveryResults={discoveryResults}
|
discoveryResults={discoveryResults}
|
||||||
@@ -356,7 +342,6 @@ describe('FolderTrustDialog', () => {
|
|||||||
{ width: 80 },
|
{ width: 80 },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('This folder contains:');
|
expect(lastFrame()).toContain('This folder contains:');
|
||||||
expect(lastFrame()).toContain('• Commands (2):');
|
expect(lastFrame()).toContain('• Commands (2):');
|
||||||
expect(lastFrame()).toContain('- cmd1');
|
expect(lastFrame()).toContain('- cmd1');
|
||||||
@@ -386,14 +371,13 @@ describe('FolderTrustDialog', () => {
|
|||||||
discoveryErrors: [],
|
discoveryErrors: [],
|
||||||
securityWarnings: ['Dangerous setting detected!'],
|
securityWarnings: ['Dangerous setting detected!'],
|
||||||
};
|
};
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog
|
<FolderTrustDialog
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
discoveryResults={discoveryResults}
|
discoveryResults={discoveryResults}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('Security Warnings:');
|
expect(lastFrame()).toContain('Security Warnings:');
|
||||||
expect(lastFrame()).toContain('Dangerous setting detected!');
|
expect(lastFrame()).toContain('Dangerous setting detected!');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -410,14 +394,13 @@ describe('FolderTrustDialog', () => {
|
|||||||
discoveryErrors: ['Failed to load custom commands'],
|
discoveryErrors: ['Failed to load custom commands'],
|
||||||
securityWarnings: [],
|
securityWarnings: [],
|
||||||
};
|
};
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog
|
<FolderTrustDialog
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
discoveryResults={discoveryResults}
|
discoveryResults={discoveryResults}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('Discovery Errors:');
|
expect(lastFrame()).toContain('Discovery Errors:');
|
||||||
expect(lastFrame()).toContain('Failed to load custom commands');
|
expect(lastFrame()).toContain('Failed to load custom commands');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -434,7 +417,7 @@ describe('FolderTrustDialog', () => {
|
|||||||
discoveryErrors: [],
|
discoveryErrors: [],
|
||||||
securityWarnings: [],
|
securityWarnings: [],
|
||||||
};
|
};
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog
|
<FolderTrustDialog
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
discoveryResults={discoveryResults}
|
discoveryResults={discoveryResults}
|
||||||
@@ -447,7 +430,6 @@ describe('FolderTrustDialog', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
// In alternate buffer + expanded, the title should be visible (StickyHeader)
|
// In alternate buffer + expanded, the title should be visible (StickyHeader)
|
||||||
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
expect(lastFrame()).toContain('Do you trust the files in this folder?');
|
||||||
// And it should NOT use MaxSizedBox truncation
|
// And it should NOT use MaxSizedBox truncation
|
||||||
@@ -470,7 +452,7 @@ describe('FolderTrustDialog', () => {
|
|||||||
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
|
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<FolderTrustDialog
|
<FolderTrustDialog
|
||||||
onSelect={vi.fn()}
|
onSelect={vi.fn()}
|
||||||
discoveryResults={discoveryResults}
|
discoveryResults={discoveryResults}
|
||||||
@@ -478,7 +460,6 @@ describe('FolderTrustDialog', () => {
|
|||||||
{ width: 100, uiState: { terminalHeight: 40 } },
|
{ width: 100, uiState: { terminalHeight: 40 } },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|
||||||
expect(output).toContain('cmd-with-ansi');
|
expect(output).toContain('cmd-with-ansi');
|
||||||
|
|||||||
@@ -138,33 +138,25 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders the component', async () => {
|
it('renders the component', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
branchName: defaultProps.branchName,
|
||||||
uiState: {
|
sessionStats: mockSessionStats,
|
||||||
branchName: defaultProps.branchName,
|
|
||||||
sessionStats: mockSessionStats,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toBeDefined();
|
expect(lastFrame()).toBeDefined();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('path display', () => {
|
describe('path display', () => {
|
||||||
it('should display a shortened path on a narrow terminal', async () => {
|
it('should display a shortened path on a narrow terminal', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 79,
|
||||||
config: mockConfig,
|
uiState: { sessionStats: mockSessionStats },
|
||||||
width: 79,
|
});
|
||||||
uiState: { sessionStats: mockSessionStats },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toBeDefined();
|
expect(output).toBeDefined();
|
||||||
// Should contain some part of the path, likely shortened
|
// Should contain some part of the path, likely shortened
|
||||||
@@ -173,15 +165,11 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use wide layout at 80 columns', async () => {
|
it('should use wide layout at 80 columns', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 80,
|
||||||
config: mockConfig,
|
uiState: { sessionStats: mockSessionStats },
|
||||||
width: 80,
|
});
|
||||||
uiState: { sessionStats: mockSessionStats },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toBeDefined();
|
expect(output).toBeDefined();
|
||||||
expect(output).toContain(path.join('make', 'it'));
|
expect(output).toContain(path.join('make', 'it'));
|
||||||
@@ -189,28 +177,24 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not truncate high-priority items on narrow terminals (regression)', async () => {
|
it('should not truncate high-priority items on narrow terminals (regression)', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 60,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 60,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
|
||||||
sessionStats: mockSessionStats,
|
|
||||||
},
|
|
||||||
settings: createMockSettings({
|
|
||||||
general: {
|
|
||||||
vimMode: true,
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
footer: {
|
|
||||||
showLabels: true,
|
|
||||||
items: ['workspace', 'model-name'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
settings: createMockSettings({
|
||||||
await waitUntilReady();
|
general: {
|
||||||
|
vimMode: true,
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
footer: {
|
||||||
|
showLabels: true,
|
||||||
|
items: ['workspace', 'model-name'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
// [INSERT] is high priority and should be fully visible
|
// [INSERT] is high priority and should be fully visible
|
||||||
// (Note: VimModeProvider defaults to 'INSERT' mode when enabled)
|
// (Note: VimModeProvider defaults to 'INSERT' mode when enabled)
|
||||||
@@ -222,168 +206,140 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays the branch name when provided', async () => {
|
it('displays the branch name when provided', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
branchName: defaultProps.branchName,
|
||||||
uiState: {
|
sessionStats: mockSessionStats,
|
||||||
branchName: defaultProps.branchName,
|
|
||||||
sessionStats: mockSessionStats,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain(defaultProps.branchName);
|
expect(lastFrame()).toContain(defaultProps.branchName);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not display the branch name when not provided', async () => {
|
it('does not display the branch name when not provided', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: { branchName: undefined, sessionStats: mockSessionStats },
|
||||||
width: 120,
|
});
|
||||||
uiState: { branchName: undefined, sessionStats: mockSessionStats },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).not.toContain('Branch');
|
expect(lastFrame()).not.toContain('Branch');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays the model name and context percentage', async () => {
|
it('displays the model name and context percentage', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
currentModel: defaultProps.model,
|
||||||
uiState: {
|
sessionStats: {
|
||||||
currentModel: defaultProps.model,
|
...mockSessionStats,
|
||||||
sessionStats: {
|
lastPromptTokenCount: 1000,
|
||||||
...mockSessionStats,
|
},
|
||||||
lastPromptTokenCount: 1000,
|
},
|
||||||
|
settings: createMockSettings({
|
||||||
|
ui: {
|
||||||
|
footer: {
|
||||||
|
hideContextPercentage: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: createMockSettings({
|
}),
|
||||||
ui: {
|
});
|
||||||
footer: {
|
|
||||||
hideContextPercentage: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain(defaultProps.model);
|
expect(lastFrame()).toContain(defaultProps.model);
|
||||||
expect(lastFrame()).toMatch(/\d+% used/);
|
expect(lastFrame()).toMatch(/\d+% used/);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays the usage indicator when usage is low', async () => {
|
it('displays the usage indicator when usage is low', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
quota: {
|
||||||
sessionStats: mockSessionStats,
|
userTier: undefined,
|
||||||
quota: {
|
stats: {
|
||||||
userTier: undefined,
|
remaining: 15,
|
||||||
stats: {
|
limit: 100,
|
||||||
remaining: 15,
|
resetTime: undefined,
|
||||||
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(lastFrame()).toContain('85%');
|
||||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides the usage indicator when usage is not near limit', async () => {
|
it('hides the usage indicator when usage is not near limit', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
quota: {
|
||||||
sessionStats: mockSessionStats,
|
userTier: undefined,
|
||||||
quota: {
|
stats: {
|
||||||
userTier: undefined,
|
remaining: 85,
|
||||||
stats: {
|
limit: 100,
|
||||||
remaining: 85,
|
resetTime: undefined,
|
||||||
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())).not.toContain('used');
|
||||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays "Limit reached" message when remaining is 0', async () => {
|
it('displays "Limit reached" message when remaining is 0', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
quota: {
|
||||||
sessionStats: mockSessionStats,
|
userTier: undefined,
|
||||||
quota: {
|
stats: {
|
||||||
userTier: undefined,
|
remaining: 0,
|
||||||
stats: {
|
limit: 100,
|
||||||
remaining: 0,
|
resetTime: undefined,
|
||||||
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(lastFrame()?.toLowerCase()).toContain('limit reached');
|
||||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
|
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 99,
|
||||||
config: mockConfig,
|
uiState: { sessionStats: mockSessionStats },
|
||||||
width: 99,
|
settings: createMockSettings({
|
||||||
uiState: { sessionStats: mockSessionStats },
|
ui: {
|
||||||
settings: createMockSettings({
|
footer: {
|
||||||
ui: {
|
hideContextPercentage: false,
|
||||||
footer: {
|
|
||||||
hideContextPercentage: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
},
|
}),
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain(defaultProps.model);
|
expect(lastFrame()).toContain(defaultProps.model);
|
||||||
expect(lastFrame()).toMatch(/\d+%/);
|
expect(lastFrame()).toMatch(/\d+%/);
|
||||||
expect(lastFrame()).not.toContain('context used');
|
expect(lastFrame()).not.toContain('context used');
|
||||||
@@ -392,33 +348,25 @@ describe('<Footer />', () => {
|
|||||||
|
|
||||||
describe('sandbox and trust info', () => {
|
describe('sandbox and trust info', () => {
|
||||||
it('should display untrusted when isTrustedFolder is false', async () => {
|
it('should display untrusted when isTrustedFolder is false', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
|
||||||
width: 120,
|
});
|
||||||
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('untrusted');
|
expect(lastFrame()).toContain('untrusted');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display custom sandbox info when SANDBOX env is set', async () => {
|
it('should display custom sandbox info when SANDBOX env is set', async () => {
|
||||||
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
isTrustedFolder: undefined,
|
||||||
uiState: {
|
sessionStats: mockSessionStats,
|
||||||
isTrustedFolder: undefined,
|
|
||||||
sessionStats: mockSessionStats,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('test');
|
expect(lastFrame()).toContain('test');
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
unmount();
|
unmount();
|
||||||
@@ -427,15 +375,11 @@ describe('<Footer />', () => {
|
|||||||
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', async () => {
|
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', async () => {
|
||||||
vi.stubEnv('SANDBOX', 'sandbox-exec');
|
vi.stubEnv('SANDBOX', 'sandbox-exec');
|
||||||
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
|
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
|
||||||
width: 120,
|
});
|
||||||
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
|
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
unmount();
|
unmount();
|
||||||
@@ -444,15 +388,11 @@ describe('<Footer />', () => {
|
|||||||
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', async () => {
|
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', async () => {
|
||||||
// Clear any SANDBOX env var that might be set.
|
// Clear any SANDBOX env var that might be set.
|
||||||
vi.stubEnv('SANDBOX', '');
|
vi.stubEnv('SANDBOX', '');
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
|
||||||
width: 120,
|
});
|
||||||
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('no sandbox');
|
expect(lastFrame()).toContain('no sandbox');
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
unmount();
|
unmount();
|
||||||
@@ -460,15 +400,11 @@ describe('<Footer />', () => {
|
|||||||
|
|
||||||
it('should prioritize untrusted message over sandbox info', async () => {
|
it('should prioritize untrusted message over sandbox info', async () => {
|
||||||
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
|
||||||
width: 120,
|
});
|
||||||
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('untrusted');
|
expect(lastFrame()).toContain('untrusted');
|
||||||
expect(lastFrame()).not.toMatch(/test-sandbox/s);
|
expect(lastFrame()).not.toMatch(/test-sandbox/s);
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
@@ -478,22 +414,18 @@ describe('<Footer />', () => {
|
|||||||
|
|
||||||
describe('footer configuration filtering (golden snapshots)', () => {
|
describe('footer configuration filtering (golden snapshots)', () => {
|
||||||
it('renders complete footer with all sections visible (baseline)', async () => {
|
it('renders complete footer with all sections visible (baseline)', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: { sessionStats: mockSessionStats },
|
||||||
width: 120,
|
settings: createMockSettings({
|
||||||
uiState: { sessionStats: mockSessionStats },
|
ui: {
|
||||||
settings: createMockSettings({
|
footer: {
|
||||||
ui: {
|
hideContextPercentage: false,
|
||||||
footer: {
|
|
||||||
hideContextPercentage: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
},
|
}),
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
||||||
'complete-footer-wide',
|
'complete-footer-wide',
|
||||||
);
|
);
|
||||||
@@ -523,47 +455,39 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders footer with only model info hidden (partial filtering)', async () => {
|
it('renders footer with only model info hidden (partial filtering)', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: { sessionStats: mockSessionStats },
|
||||||
width: 120,
|
settings: createMockSettings({
|
||||||
uiState: { sessionStats: mockSessionStats },
|
ui: {
|
||||||
settings: createMockSettings({
|
footer: {
|
||||||
ui: {
|
hideCWD: false,
|
||||||
footer: {
|
hideSandboxStatus: false,
|
||||||
hideCWD: false,
|
hideModelInfo: true,
|
||||||
hideSandboxStatus: false,
|
|
||||||
hideModelInfo: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
},
|
}),
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
|
expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', async () => {
|
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: { sessionStats: mockSessionStats },
|
||||||
width: 120,
|
settings: createMockSettings({
|
||||||
uiState: { sessionStats: mockSessionStats },
|
ui: {
|
||||||
settings: createMockSettings({
|
footer: {
|
||||||
ui: {
|
hideCWD: true,
|
||||||
footer: {
|
hideSandboxStatus: false,
|
||||||
hideCWD: true,
|
hideModelInfo: true,
|
||||||
hideSandboxStatus: false,
|
|
||||||
hideModelInfo: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
},
|
}),
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
||||||
'footer-only-sandbox',
|
'footer-only-sandbox',
|
||||||
);
|
);
|
||||||
@@ -571,64 +495,52 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('hides the context percentage when hideContextPercentage is true', async () => {
|
it('hides the context percentage when hideContextPercentage is true', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: { sessionStats: mockSessionStats },
|
||||||
width: 120,
|
settings: createMockSettings({
|
||||||
uiState: { sessionStats: mockSessionStats },
|
ui: {
|
||||||
settings: createMockSettings({
|
footer: {
|
||||||
ui: {
|
hideContextPercentage: true,
|
||||||
footer: {
|
|
||||||
hideContextPercentage: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
},
|
}),
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain(defaultProps.model);
|
expect(lastFrame()).toContain(defaultProps.model);
|
||||||
expect(lastFrame()).not.toMatch(/\d+% used/);
|
expect(lastFrame()).not.toMatch(/\d+% used/);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
it('shows the context percentage when hideContextPercentage is false', async () => {
|
it('shows the context percentage when hideContextPercentage is false', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: { sessionStats: mockSessionStats },
|
||||||
width: 120,
|
settings: createMockSettings({
|
||||||
uiState: { sessionStats: mockSessionStats },
|
ui: {
|
||||||
settings: createMockSettings({
|
footer: {
|
||||||
ui: {
|
hideContextPercentage: false,
|
||||||
footer: {
|
|
||||||
hideContextPercentage: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
},
|
}),
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain(defaultProps.model);
|
expect(lastFrame()).toContain(defaultProps.model);
|
||||||
expect(lastFrame()).toMatch(/\d+% used/);
|
expect(lastFrame()).toMatch(/\d+% used/);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
|
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 79,
|
||||||
config: mockConfig,
|
uiState: { sessionStats: mockSessionStats },
|
||||||
width: 79,
|
settings: createMockSettings({
|
||||||
uiState: { sessionStats: mockSessionStats },
|
ui: {
|
||||||
settings: createMockSettings({
|
footer: {
|
||||||
ui: {
|
hideContextPercentage: false,
|
||||||
footer: {
|
|
||||||
hideContextPercentage: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
},
|
}),
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
|
||||||
'complete-footer-narrow',
|
'complete-footer-narrow',
|
||||||
);
|
);
|
||||||
@@ -714,60 +626,48 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('hides error summary in low verbosity mode out of dev mode', async () => {
|
it('hides error summary in low verbosity mode out of dev mode', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
errorCount: 2,
|
||||||
sessionStats: mockSessionStats,
|
showErrorDetails: false,
|
||||||
errorCount: 2,
|
|
||||||
showErrorDetails: false,
|
|
||||||
},
|
|
||||||
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
|
|
||||||
},
|
},
|
||||||
);
|
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
|
||||||
await waitUntilReady();
|
});
|
||||||
expect(lastFrame()).not.toContain('F12 for details');
|
expect(lastFrame()).not.toContain('F12 for details');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error summary in low verbosity mode in dev mode', async () => {
|
it('shows error summary in low verbosity mode in dev mode', async () => {
|
||||||
mocks.isDevelopment = true;
|
mocks.isDevelopment = true;
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
errorCount: 2,
|
||||||
sessionStats: mockSessionStats,
|
showErrorDetails: false,
|
||||||
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('F12 for details');
|
||||||
expect(lastFrame()).toContain('2 errors');
|
expect(lastFrame()).toContain('2 errors');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error summary in full verbosity mode', async () => {
|
it('shows error summary in full verbosity mode', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
errorCount: 2,
|
||||||
sessionStats: mockSessionStats,
|
showErrorDetails: false,
|
||||||
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('F12 for details');
|
||||||
expect(lastFrame()).toContain('2 errors');
|
expect(lastFrame()).toContain('2 errors');
|
||||||
unmount();
|
unmount();
|
||||||
@@ -776,25 +676,21 @@ describe('<Footer />', () => {
|
|||||||
|
|
||||||
describe('Footer Custom Items', () => {
|
describe('Footer Custom Items', () => {
|
||||||
it('renders items in the specified order', async () => {
|
it('renders items in the specified order', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
currentModel: 'gemini-pro',
|
||||||
uiState: {
|
sessionStats: mockSessionStats,
|
||||||
currentModel: 'gemini-pro',
|
|
||||||
sessionStats: mockSessionStats,
|
|
||||||
},
|
|
||||||
settings: createMockSettings({
|
|
||||||
ui: {
|
|
||||||
footer: {
|
|
||||||
items: ['model-name', 'workspace'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
settings: createMockSettings({
|
||||||
await waitUntilReady();
|
ui: {
|
||||||
|
footer: {
|
||||||
|
items: ['model-name', 'workspace'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
const modelIdx = output.indexOf('/model');
|
const modelIdx = output.indexOf('/model');
|
||||||
@@ -804,28 +700,24 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders multiple items with proper alignment', async () => {
|
it('renders multiple items with proper alignment', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
branchName: 'main',
|
||||||
sessionStats: mockSessionStats,
|
|
||||||
branchName: 'main',
|
|
||||||
},
|
|
||||||
settings: createMockSettings({
|
|
||||||
vimMode: {
|
|
||||||
vimMode: true,
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
footer: {
|
|
||||||
items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
settings: createMockSettings({
|
||||||
await waitUntilReady();
|
vimMode: {
|
||||||
|
vimMode: true,
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
footer: {
|
||||||
|
items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toBeDefined();
|
expect(output).toBeDefined();
|
||||||
@@ -862,25 +754,21 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not render items that are conditionally hidden', async () => {
|
it('does not render items that are conditionally hidden', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
branchName: undefined, // No branch
|
||||||
sessionStats: mockSessionStats,
|
|
||||||
branchName: undefined, // No branch
|
|
||||||
},
|
|
||||||
settings: createMockSettings({
|
|
||||||
ui: {
|
|
||||||
footer: {
|
|
||||||
items: ['workspace', 'git-branch', 'model-name'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
settings: createMockSettings({
|
||||||
await waitUntilReady();
|
ui: {
|
||||||
|
footer: {
|
||||||
|
items: ['workspace', 'git-branch', 'model-name'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
expect(output).toBeDefined();
|
expect(output).toBeDefined();
|
||||||
@@ -893,18 +781,14 @@ describe('<Footer />', () => {
|
|||||||
|
|
||||||
describe('fallback mode display', () => {
|
describe('fallback mode display', () => {
|
||||||
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
|
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
|
||||||
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)
|
// Footer should show the effective model (Flash), not the config model (Pro)
|
||||||
expect(lastFrame()).toContain('gemini-2.5-flash');
|
expect(lastFrame()).toContain('gemini-2.5-flash');
|
||||||
@@ -913,18 +797,14 @@ describe('<Footer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display Pro model when NOT in fallback mode', async () => {
|
it('should display Pro model when NOT in fallback mode', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
config: mockConfig,
|
||||||
{
|
width: 120,
|
||||||
config: mockConfig,
|
uiState: {
|
||||||
width: 120,
|
sessionStats: mockSessionStats,
|
||||||
uiState: {
|
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
|
||||||
sessionStats: mockSessionStats,
|
|
||||||
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
expect(lastFrame()).toContain('gemini-2.5-pro');
|
expect(lastFrame()).toContain('gemini-2.5-pro');
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -30,19 +30,17 @@ describe('<FooterConfigDialog />', () => {
|
|||||||
{ settings },
|
{ settings },
|
||||||
);
|
);
|
||||||
|
|
||||||
await renderResult.waitUntilReady();
|
|
||||||
expect(renderResult.lastFrame()).toMatchSnapshot();
|
expect(renderResult.lastFrame()).toMatchSnapshot();
|
||||||
await expect(renderResult).toMatchSvgSnapshot();
|
await expect(renderResult).toMatchSvgSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toggles an item when enter is pressed', async () => {
|
it('toggles an item when enter is pressed', async () => {
|
||||||
const settings = createMockSettings();
|
const settings = createMockSettings();
|
||||||
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, stdin } = await renderWithProviders(
|
||||||
<FooterConfigDialog onClose={mockOnClose} />,
|
<FooterConfigDialog onClose={mockOnClose} />,
|
||||||
{ settings },
|
{ settings },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
act(() => {
|
act(() => {
|
||||||
stdin.write('\r'); // Enter to toggle
|
stdin.write('\r'); // Enter to toggle
|
||||||
});
|
});
|
||||||
@@ -62,12 +60,11 @@ describe('<FooterConfigDialog />', () => {
|
|||||||
|
|
||||||
it('reorders items with arrow keys', async () => {
|
it('reorders items with arrow keys', async () => {
|
||||||
const settings = createMockSettings();
|
const settings = createMockSettings();
|
||||||
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, stdin } = await renderWithProviders(
|
||||||
<FooterConfigDialog onClose={mockOnClose} />,
|
<FooterConfigDialog onClose={mockOnClose} />,
|
||||||
{ settings },
|
{ settings },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
// Initial order: workspace, git-branch, ...
|
// Initial order: workspace, git-branch, ...
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
const cwdIdx = output.indexOf('] workspace');
|
const cwdIdx = output.indexOf('] workspace');
|
||||||
@@ -93,12 +90,11 @@ describe('<FooterConfigDialog />', () => {
|
|||||||
|
|
||||||
it('closes on Esc', async () => {
|
it('closes on Esc', async () => {
|
||||||
const settings = createMockSettings();
|
const settings = createMockSettings();
|
||||||
const { stdin, waitUntilReady } = await renderWithProviders(
|
const { stdin } = await renderWithProviders(
|
||||||
<FooterConfigDialog onClose={mockOnClose} />,
|
<FooterConfigDialog onClose={mockOnClose} />,
|
||||||
{ settings },
|
{ settings },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
act(() => {
|
act(() => {
|
||||||
stdin.write('\x1b'); // Esc
|
stdin.write('\x1b'); // Esc
|
||||||
});
|
});
|
||||||
@@ -115,9 +111,8 @@ describe('<FooterConfigDialog />', () => {
|
|||||||
{ settings },
|
{ settings },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame, stdin, waitUntilReady } = renderResult;
|
const { lastFrame, stdin } = renderResult;
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('~/project/path');
|
expect(lastFrame()).toContain('~/project/path');
|
||||||
|
|
||||||
// Move focus down to 'code-changes' (which has colored elements)
|
// Move focus down to 'code-changes' (which has colored elements)
|
||||||
@@ -148,13 +143,11 @@ describe('<FooterConfigDialog />', () => {
|
|||||||
|
|
||||||
it('shows an empty preview when all items are deselected', async () => {
|
it('shows an empty preview when all items are deselected', async () => {
|
||||||
const settings = createMockSettings();
|
const settings = createMockSettings();
|
||||||
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, stdin } = await renderWithProviders(
|
||||||
<FooterConfigDialog onClose={mockOnClose} />,
|
<FooterConfigDialog onClose={mockOnClose} />,
|
||||||
{ settings },
|
{ settings },
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Default items are the first 5. We toggle them off.
|
// Default items are the first 5. We toggle them off.
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -178,11 +171,10 @@ describe('<FooterConfigDialog />', () => {
|
|||||||
|
|
||||||
it('moves item correctly after trying to move up at the top', async () => {
|
it('moves item correctly after trying to move up at the top', async () => {
|
||||||
const settings = createMockSettings();
|
const settings = createMockSettings();
|
||||||
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
|
const { lastFrame, stdin } = await renderWithProviders(
|
||||||
<FooterConfigDialog onClose={mockOnClose} />,
|
<FooterConfigDialog onClose={mockOnClose} />,
|
||||||
{ settings },
|
{ settings },
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// Default initial items in mock settings are 'git-branch', 'workspace', ...
|
// Default initial items in mock settings are 'git-branch', 'workspace', ...
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -222,8 +214,7 @@ describe('<FooterConfigDialog />', () => {
|
|||||||
{ settings },
|
{ settings },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { lastFrame, stdin, waitUntilReady } = renderResult;
|
const { lastFrame, stdin } = renderResult;
|
||||||
await waitUntilReady();
|
|
||||||
|
|
||||||
// By default labels are on
|
// By default labels are on
|
||||||
expect(lastFrame()).toContain('workspace (/directory)');
|
expect(lastFrame()).toContain('workspace (/directory)');
|
||||||
|
|||||||
@@ -41,10 +41,7 @@ describe('GeminiRespondingSpinner', () => {
|
|||||||
|
|
||||||
it('renders spinner when responding', async () => {
|
it('renders spinner when responding', async () => {
|
||||||
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
|
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
|
||||||
<GeminiRespondingSpinner />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('GeminiSpinner');
|
expect(lastFrame()).toContain('GeminiSpinner');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -52,30 +49,23 @@ describe('GeminiRespondingSpinner', () => {
|
|||||||
it('renders screen reader text when responding and screen reader enabled', async () => {
|
it('renders screen reader text when responding and screen reader enabled', async () => {
|
||||||
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
|
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
|
||||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
|
||||||
<GeminiRespondingSpinner />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
|
expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders nothing when not responding and no non-responding display', async () => {
|
it('renders nothing when not responding and no non-responding display', async () => {
|
||||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
|
||||||
<GeminiRespondingSpinner />,
|
|
||||||
);
|
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders non-responding display when provided', async () => {
|
it('renders non-responding display when provided', async () => {
|
||||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
|
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain('Waiting...');
|
expect(lastFrame()).toContain('Waiting...');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -83,10 +73,9 @@ describe('GeminiRespondingSpinner', () => {
|
|||||||
it('renders screen reader loading text when non-responding display provided and screen reader enabled', async () => {
|
it('renders screen reader loading text when non-responding display provided and screen reader enabled', async () => {
|
||||||
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
|
||||||
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
mockUseIsScreenReaderEnabled.mockReturnValue(true);
|
||||||
const { lastFrame, waitUntilReady, unmount } = render(
|
const { lastFrame, unmount } = await render(
|
||||||
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
|
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toContain(SCREEN_READER_LOADING);
|
expect(lastFrame()).toContain(SCREEN_READER_LOADING);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import * as SessionContext from '../contexts/SessionContext.js';
|
|||||||
import { type SessionStatsState } from '../contexts/SessionContext.js';
|
import { type SessionStatsState } from '../contexts/SessionContext.js';
|
||||||
import { Banner } from './Banner.js';
|
import { Banner } from './Banner.js';
|
||||||
import { Footer } from './Footer.js';
|
import { Footer } from './Footer.js';
|
||||||
import { Header } from './Header.js';
|
import { AppHeader } from './AppHeader.js';
|
||||||
import { ModelDialog } from './ModelDialog.js';
|
import { ModelDialog } from './ModelDialog.js';
|
||||||
import { StatsDisplay } from './StatsDisplay.js';
|
import { StatsDisplay } from './StatsDisplay.js';
|
||||||
|
|
||||||
@@ -71,54 +71,47 @@ useSessionStatsMock.mockReturnValue({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Gradient Crash Regression Tests', () => {
|
describe('Gradient Crash Regression Tests', () => {
|
||||||
it('<Header /> should not crash when theme.ui.gradient is empty', async () => {
|
it('<AppHeader /> should not crash when theme.ui.gradient is empty', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<Header version="1.0.0" nightly={false} />,
|
<AppHeader version="1.0.0" />,
|
||||||
{
|
{
|
||||||
width: 120,
|
width: 120,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toBeDefined();
|
expect(lastFrame()).toBeDefined();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('<ModelDialog /> should not crash when theme.ui.gradient is empty', async () => {
|
it('<ModelDialog /> should not crash when theme.ui.gradient is empty', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<ModelDialog onClose={async () => {}} />,
|
<ModelDialog onClose={async () => {}} />,
|
||||||
{
|
{
|
||||||
width: 120,
|
width: 120,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toBeDefined();
|
expect(lastFrame()).toBeDefined();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('<Banner /> should not crash when theme.ui.gradient is empty', async () => {
|
it('<Banner /> should not crash when theme.ui.gradient is empty', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<Banner bannerText="Test Banner" isWarning={false} width={80} />,
|
<Banner bannerText="Test Banner" isWarning={false} width={80} />,
|
||||||
{
|
{
|
||||||
width: 120,
|
width: 120,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toBeDefined();
|
expect(lastFrame()).toBeDefined();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('<Footer /> should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', async () => {
|
it('<Footer /> should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
|
||||||
<Footer />,
|
width: 120,
|
||||||
{
|
uiState: {
|
||||||
width: 120,
|
nightly: true, // Enable nightly to trigger Gradient usage logic
|
||||||
uiState: {
|
sessionStats: mockSessionStats,
|
||||||
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
|
// If it crashes, this line won't be reached or lastFrame() will throw
|
||||||
expect(lastFrame()).toBeDefined();
|
expect(lastFrame()).toBeDefined();
|
||||||
// It should fall back to rendering text without gradient
|
// It should fall back to rendering text without gradient
|
||||||
@@ -127,7 +120,7 @@ describe('Gradient Crash Regression Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('<StatsDisplay /> should not crash when theme.ui.gradient is empty', async () => {
|
it('<StatsDisplay /> should not crash when theme.ui.gradient is empty', async () => {
|
||||||
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<StatsDisplay duration="1s" title="My Stats" />,
|
<StatsDisplay duration="1s" title="My Stats" />,
|
||||||
{
|
{
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -136,7 +129,6 @@ describe('Gradient Crash Regression Tests', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await waitUntilReady();
|
|
||||||
expect(lastFrame()).toBeDefined();
|
expect(lastFrame()).toBeDefined();
|
||||||
// Ensure title is rendered
|
// Ensure title is rendered
|
||||||
expect(lastFrame()).toContain('My Stats');
|
expect(lastFrame()).toContain('My Stats');
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user