Compare commits

..

16 Commits

Author SHA1 Message Date
Chris Williams cad496650b Fix up links in README 2026-03-20 14:29:30 -07:00
Chris Williams 38de78045d Add redirects 2026-03-20 14:25:44 -07:00
Chris Williams dce25e4229 Move docs into resources 2026-03-20 14:25:44 -07:00
Chris Williams 59bf8cbb45 Shift heavy install and authentication docs out of getting start and into resources 2026-03-20 14:25:40 -07:00
cynthialong0-0 4e80f01fda feat(core): add security prompt for browser agent (#23241) 2026-03-20 20:12:24 +00:00
Tommaso Sciortino 6c78eb7a39 test(cli): refactor tests for async render utilities (#23252) 2026-03-20 20:08:29 +00:00
kevinjwang1 86a3a913b5 Fix schema for ModelChains (#23284) 2026-03-20 19:50:15 +00:00
Sehoon Shon 05e4ea80ee feat(core): refine User-Agent for VS Code traffic (unified format) (#23256) 2026-03-20 19:31:01 +00:00
Dev Randalpura cca595971d fix(core): add sanitization to sub agent thoughts and centralize utilities (#22828) 2026-03-20 19:19:18 +00:00
Sandy Tao 26b9af1cdc refactor(core): use absolute paths in GEMINI.md context markers (#23135) 2026-03-20 19:10:01 +00:00
Jerop Kipruto b459e1a108 feat(telemetry): track if session is running in a Git worktree (#23265) 2026-03-20 19:01:12 +00:00
Ratish P 62cb14fa52 feat(extensions): add --skip-settings flag to install command (#17212) 2026-03-20 16:10:59 +00:00
kevinjwang1 7a65c1e91d Add support for linking in the extension registry (#23153) 2026-03-20 15:08:34 +00:00
Jerop Kipruto 5a3c7154df feat(worktree): add Git worktree support for isolated parallel sessions (#22973) 2026-03-20 14:10:51 +00:00
Michael Bleigh b9c87c14a2 feat(core): introduce AgentSession and rename stream events to agent events (#23159) 2026-03-20 13:40:10 +00:00
nmcnamara-eng 52250c162d feat(cli): conditionally exclude ask_user tool in ACP mode (#23045)
Co-authored-by: Sri Pasumarthi <sripas@google.com>
Co-authored-by: Sri Pasumarthi <111310667+sripasg@users.noreply.github.com>
2026-03-20 06:18:55 +00:00
268 changed files with 6407 additions and 5975 deletions
+4 -4
View File
@@ -30,7 +30,7 @@ Learn all about Gemini CLI in our [documentation](https://geminicli.com/docs/).
## 📦 Installation
See
[Gemini CLI installation, execution, and releases](./docs/get-started/installation.md)
[Gemini CLI installation, execution, and releases](./docs/resources/installation.md)
for recommended system specifications and a detailed installation guide.
### Quick Install
@@ -209,7 +209,7 @@ gemini
```
For Google Workspace accounts and other authentication methods, see the
[authentication guide](./docs/get-started/authentication.md).
[authentication guide](./docs/resources/authentication.md).
## 🚀 Getting Started
@@ -280,8 +280,8 @@ gemini
- [**Quickstart Guide**](./docs/get-started/index.md) - Get up and running
quickly.
- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed
auth configuration.
- [**Authentication Setup**](./docs/resources/authentication.md) - Detailed auth
configuration.
- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and
customization.
- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) -
+1
View File
@@ -50,6 +50,7 @@ These commands are available within the interactive REPL.
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. |
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
+1 -1
View File
@@ -508,7 +508,7 @@ other events. For more information, see the
You can enforce a specific authentication method for all users by setting the
`enforcedAuthType` in the system-level `settings.json` file. This prevents users
from choosing a different authentication method. See the
[Authentication docs](../get-started/authentication.md) for more details.
[Authentication docs](../resources/authentication.md) for more details.
**Example:** Enforce the use of Google login for all users.
+107
View File
@@ -0,0 +1,107 @@
# Git Worktrees (experimental)
When working on multiple tasks at once, you can use Git worktrees to give each
Gemini session its own copy of the codebase. Git worktrees create separate
working directories that each have their own files and branch while sharing the
same repository history. This prevents changes in one session from colliding
with another.
Learn more about [session management](./session-management.md).
<!-- prettier-ignore -->
> [!NOTE]
> This is an experimental feature currently under active development. Your
> feedback is invaluable as we refine this feature. If you have ideas,
> suggestions, or encounter issues:
>
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub.
> - Use the **/bug** command within Gemini CLI to file an issue.
Learn more in the official Git worktree
[documentation](https://git-scm.com/docs/git-worktree).
## How to enable Git worktrees
Git worktrees are an experimental feature. You must enable them in your settings
using the `/settings` command or by manually editing your `settings.json` file.
1. Use the `/settings` command.
2. Search for and set **Enable Git Worktrees** to `true`.
Alternatively, add the following to your `settings.json`:
```json
{
"experimental": {
"worktrees": true
}
}
```
## How to use Git worktrees
Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini
CLI in it.
- **Start with a specific name:** The value you pass becomes both the directory
name (within `.gemini/worktrees/`) and the branch name.
```bash
gemini --worktree feature-search
```
- **Start with a random name:** If you omit the name, Gemini generates a random
one automatically (for example, `worktree-a1b2c3d4`).
```bash
gemini --worktree
```
<!-- prettier-ignore -->
> [!NOTE]
> Remember to initialize your development environment in each new
> worktree according to your project's setup. Depending on your stack, this
> might include running dependency installation (`npm install`, `yarn`), setting
> up virtual environments, or following your project's standard build process.
## How to exit a Git worktree session
When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the
worktree intact so your work is not lost. This includes your uncommitted changes
(modified files, staged changes, or untracked files) and any new commits you
have made.
Gemini prioritizes a fast and safe exit: it **does not automatically delete**
your worktree or branch. You are responsible for cleaning up your worktrees
manually once you are finished with them.
When you exit, Gemini displays instructions on how to resume your work or how to
manually remove the worktree if you no longer need it.
## Resuming work in a Git worktree
To resume a session in a worktree, navigate to the worktree directory and start
Gemini CLI with the `--resume` flag and the session ID:
```bash
cd .gemini/worktrees/feature-search
gemini --resume <session_id>
```
## Managing Git worktrees manually
For more control over worktree location and branch configuration, or to clean up
a preserved worktree, you can use Git directly:
- **Clean up a preserved Git worktree:**
```bash
git worktree remove .gemini/worktrees/feature-search --force
git branch -D worktree-feature-search
```
- **Create a Git worktree manually:**
```bash
git worktree add ../project-feature-search -b feature-search
cd ../project-feature-search && gemini
```
[Open an issue]: https://github.com/google-gemini/gemini-cli/issues
+6
View File
@@ -96,6 +96,12 @@ Compatibility aliases:
- `/chat ...` works for the same commands.
- `/resume checkpoints ...` also remains supported during migration.
## Parallel sessions with Git worktrees
When working on multiple tasks at once, you can use
[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of
the codebase. This prevents changes in one session from colliding with another.
## Managing sessions
You can list and delete sessions to keep your history organized and manage disk
+1
View File
@@ -151,6 +151,7 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
+1 -1
View File
@@ -24,7 +24,7 @@ project-specific behavior or create a customized persona.
You can set the environment variable temporarily in your shell, or persist it
via a `.gemini/.env` file. See
[Persisting Environment Variables](../get-started/authentication.md#persisting-environment-variables).
[Persisting Environment Variables](../resources/authentication.md#persisting-environment-variables).
- Use the project default path (`.gemini/system.md`):
- `GEMINI_SYSTEM_MD=true` or `GEMINI_SYSTEM_MD=1`
+1
View File
@@ -306,6 +306,7 @@ Emitted at startup with the CLI configuration.
- `extension_ids` (string)
- `extensions_count` (int)
- `auth_type` (string)
- `worktree_active` (boolean)
- `github_workflow_name` (string, optional)
- `github_repository_hash` (string, optional)
- `github_event_name` (string, optional)
+2 -1
View File
@@ -23,7 +23,7 @@ Gemini CLI creates a copy of the extension during installation. You must run
GitHub, you must have `git` installed on your machine.
```bash
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent]
gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release] [--consent] [--skip-settings]
```
- `<source>`: The GitHub URL or local path of the extension.
@@ -31,6 +31,7 @@ gemini extensions install <source> [--ref <ref>] [--auto-update] [--pre-release]
- `--auto-update`: Enable automatic updates for this extension.
- `--pre-release`: Enable installation of pre-release versions.
- `--consent`: Acknowledge security risks and skip the confirmation prompt.
- `--skip-settings`: Skip the configuration on install process.
### Uninstall an extension
+3 -2
View File
@@ -24,7 +24,8 @@ Once Gemini CLI is installed, run Gemini CLI from your command line:
gemini
```
For more installation options, see [Gemini CLI Installation](./installation.md).
For more installation options, see
[Gemini CLI Installation](../resources/installation.md).
## Authenticate
@@ -46,7 +47,7 @@ cases, you can log in with your existing Google account:
Certain account types may require you to configure a Google Cloud project. For
more information, including other authentication methods, see
[Gemini CLI Authentication Setup](./authentication.md).
[Gemini CLI Authentication Setup](../resources/authentication.md).
## Configure
+7 -4
View File
@@ -10,15 +10,15 @@ context.
npm install -g @google/gemini-cli
```
For more installation options and authentication setup, see the full
[Installation](./resources/installation.md) and
[Authentication](./resources/authentication.md) guides.
## Get started
Jump in to Gemini CLI.
- **[Quickstart](./get-started/index.md):** Your first session with Gemini CLI.
- **[Installation](./get-started/installation.md):** How to install Gemini CLI
on your system.
- **[Authentication](./get-started/authentication.md):** Setup instructions for
personal and enterprise accounts.
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
action.
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
@@ -116,6 +116,9 @@ Deep technical documentation and API specifications.
Support, release history, and legal information.
- **[FAQ](./resources/faq.md):** Answers to frequently asked questions.
- **[Installation](./resources/installation.md):** How to install Gemini CLI.
- **[Authentication](./resources/authentication.md):** Setup instructions for
personal and enterprise accounts.
- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing
details.
- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and
+2
View File
@@ -11,8 +11,10 @@
"/docs/core/tools-api": "/docs/reference/tools",
"/docs/reference/tools-api": "/docs/reference/tools",
"/docs/faq": "/docs/resources/faq",
"/docs/get-started/authentication": "/docs/resources/authentication",
"/docs/get-started/configuration": "/docs/reference/configuration",
"/docs/get-started/configuration-v1": "/docs/reference/configuration",
"/docs/get-started/installation": "/docs/resources/installation",
"/docs/index": "/docs",
"/docs/quota-and-pricing": "/docs/resources/quota-and-pricing",
"/docs/tos-privacy": "/docs/resources/tos-privacy",
+8 -3
View File
@@ -1527,6 +1527,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.worktrees`** (boolean):
- **Description:** Enable automated Git worktree management for parallel work.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionManagement`** (boolean):
- **Description:** Enable extension management features.
- **Default:** `true`
@@ -1898,8 +1903,8 @@ within your user's home folder.
Environment variables are a common way to configure applications, especially for
sensitive information like API keys or for settings that might change between
environments. For authentication setup, see the
[Authentication documentation](../get-started/authentication.md) which covers
all available authentication methods.
[Authentication documentation](../resources/authentication.md) which covers all
available authentication methods.
The CLI automatically loads environment variables from an `.env` file. The
loading order is:
@@ -1919,7 +1924,7 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
- **`GEMINI_API_KEY`**:
- Your API key for the Gemini API.
- One of several available
[authentication methods](../get-started/authentication.md).
[authentication methods](../resources/authentication.md).
- Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env`
file.
- **`GEMINI_MODEL`**:
+10 -5
View File
@@ -7,11 +7,6 @@
"items": [
{ "label": "Overview", "slug": "docs" },
{ "label": "Quickstart", "slug": "docs/get-started" },
{ "label": "Installation", "slug": "docs/get-started/installation" },
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Examples", "slug": "docs/get-started/examples" },
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
{
@@ -99,6 +94,11 @@
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{
"label": "Git worktrees",
"badge": "🔬",
"slug": "docs/cli/git-worktrees"
},
{
"label": "Hooks",
"collapsed": true,
@@ -215,6 +215,11 @@
"label": "Resources",
"items": [
{ "label": "FAQ", "slug": "docs/resources/faq" },
{ "label": "Installation", "slug": "docs/resources/installation" },
{
"label": "Authentication",
"slug": "docs/resources/authentication"
},
{
"label": "Quota and pricing",
"slug": "docs/resources/quota-and-pricing"
@@ -12,48 +12,46 @@ import {
beforeEach,
afterEach,
type MockInstance,
type Mock,
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
import * as core from '@google/gemini-cli-core';
import {
ExtensionManager,
type inferInstallMetadata,
} from '../../config/extension-manager.js';
import type {
promptForConsentNonInteractive,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import type {
isWorkspaceTrusted,
loadTrustedFolders,
} from '../../config/trustedFolders.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
import * as path from 'node:path';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
const mockInstallOrUpdateExtension: Mock<
typeof ExtensionManager.prototype.installOrUpdateExtension
> = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockPromptForConsentNonInteractive: Mock<
typeof promptForConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
() => vi.fn(),
);
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
vi.fn(),
);
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
vi.fn(),
);
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
vi.hoisted(() => vi.fn());
const {
mockInstallOrUpdateExtension,
mockLoadExtensions,
mockExtensionManager,
mockRequestConsentNonInteractive,
mockPromptForConsentNonInteractive,
mockStat,
mockInferInstallMetadata,
mockIsWorkspaceTrusted,
mockLoadTrustedFolders,
mockDiscover,
} = vi.hoisted(() => {
const mockLoadExtensions = vi.fn();
const mockInstallOrUpdateExtension = vi.fn();
const mockExtensionManager = vi.fn().mockImplementation(() => ({
loadExtensions: mockLoadExtensions,
installOrUpdateExtension: mockInstallOrUpdateExtension,
}));
return {
mockLoadExtensions,
mockInstallOrUpdateExtension,
mockExtensionManager,
mockRequestConsentNonInteractive: vi.fn(),
mockPromptForConsentNonInteractive: vi.fn(),
mockStat: vi.fn(),
mockInferInstallMetadata: vi.fn(),
mockIsWorkspaceTrusted: vi.fn(),
mockLoadTrustedFolders: vi.fn(),
mockDiscover: vi.fn(),
};
});
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
@@ -84,6 +82,7 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
...(await importOriginal<
typeof import('../../config/extension-manager.js')
>()),
ExtensionManager: mockExtensionManager,
inferInstallMetadata: mockInferInstallMetadata,
}));
@@ -117,19 +116,18 @@ describe('handleInstall', () => {
let processSpy: MockInstance;
beforeEach(() => {
debugLogSpy = vi.spyOn(core.debugLogger, 'log');
debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
debugLogSpy = vi
.spyOn(core.debugLogger, 'log')
.mockImplementation(() => {});
debugErrorSpy = vi
.spyOn(core.debugLogger, 'error')
.mockImplementation(() => {});
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
[],
);
vi.spyOn(
ExtensionManager.prototype,
'installOrUpdateExtension',
).mockImplementation(mockInstallOrUpdateExtension);
mockLoadExtensions.mockResolvedValue([]);
mockInstallOrUpdateExtension.mockReset();
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
mockDiscover.mockResolvedValue({
@@ -163,12 +161,7 @@ describe('handleInstall', () => {
});
afterEach(() => {
mockInstallOrUpdateExtension.mockClear();
mockRequestConsentNonInteractive.mockClear();
mockStat.mockClear();
mockInferInstallMetadata.mockClear();
vi.clearAllMocks();
vi.restoreAllMocks();
});
function createMockExtension(
@@ -288,6 +281,39 @@ describe('handleInstall', () => {
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should pass promptForSetting when skipSettings is not provided', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'test-extension',
} as unknown as core.GeminiCLIExtension);
await handleInstall({
source: 'http://google.com',
});
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
requestSetting: promptForSetting,
}),
);
});
it('should pass null for requestSetting when skipSettings is true', async () => {
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'test-extension',
} as unknown as core.GeminiCLIExtension);
await handleInstall({
source: 'http://google.com',
skipSettings: true,
});
expect(mockExtensionManager).toHaveBeenCalledWith(
expect.objectContaining({
requestSetting: null,
}),
);
});
it('should proceed if local path is already trusted', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
@@ -37,6 +37,7 @@ interface InstallArgs {
autoUpdate?: boolean;
allowPreRelease?: boolean;
consent?: boolean;
skipSettings?: boolean;
}
export async function handleInstall(args: InstallArgs) {
@@ -153,7 +154,7 @@ export async function handleInstall(args: InstallArgs) {
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent,
requestSetting: promptForSetting,
requestSetting: args.skipSettings ? null : promptForSetting,
settings,
});
await extensionManager.loadExtensions();
@@ -196,6 +197,11 @@ export const installCommand: CommandModule = {
type: 'boolean',
default: false,
})
.option('skip-settings', {
describe: 'Skip the configuration on install process.',
type: 'boolean',
default: false,
})
.check((argv) => {
if (!argv.source) {
throw new Error('The source argument must be provided.');
@@ -214,6 +220,8 @@ export const installCommand: CommandModule = {
allowPreRelease: argv['pre-release'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
consent: argv['consent'] as boolean | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
skipSettings: argv['skip-settings'] as boolean | undefined,
});
await exitCli();
},
+69
View File
@@ -226,6 +226,51 @@ afterEach(() => {
});
describe('parseArguments', () => {
describe('worktree', () => {
it('should parse --worktree flag when provided with a name', async () => {
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = true;
const argv = await parseArguments(settings);
expect(argv.worktree).toBe('my-feature');
});
it('should generate a random name when --worktree is provided without a name', async () => {
process.argv = ['node', 'script.js', '--worktree'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = true;
const argv = await parseArguments(settings);
expect(argv.worktree).toBeDefined();
expect(argv.worktree).not.toBe('');
expect(typeof argv.worktree).toBe('string');
});
it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => {
process.argv = ['node', 'script.js', '--worktree', 'feature'];
const settings = createTestMergedSettings();
settings.experimental.worktrees = false;
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments(settings)).rejects.toThrow(
'process.exit called',
);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
});
it.each([
{
description: 'long flags',
@@ -2225,6 +2270,30 @@ describe('loadCliConfig tool exclusions', () => {
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should exclude ask_user in interactive mode when --acp is provided', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--acp'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should exclude ask_user in interactive mode when --experimental-acp is provided', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', '--experimental-acp'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
createTestMergedSettings(),
'test-session',
argv,
);
expect(config.getExcludeTools()).toContain('ask_user');
});
it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => {
process.stdin.isTTY = false;
process.argv = [
+110 -3
View File
@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import yargs from 'yargs/yargs';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import { execa } from 'execa';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
@@ -38,6 +39,9 @@ import {
applyAdminAllowlist,
applyRequiredServers,
getAdminBlockedMcpServersMessage,
getProjectRootForWorktree,
isGeminiWorktree,
type WorktreeSettings,
type HookDefinition,
type HookEventName,
type OutputFormat,
@@ -48,6 +52,8 @@ import {
type MergedSettings,
saveModelChange,
loadSettings,
isWorktreeEnabled,
type LoadedSettings,
} from './settings.js';
import { loadSandboxConfig } from './sandboxConfig.js';
@@ -74,6 +80,7 @@ export interface CliArgs {
debug: boolean | undefined;
prompt: string | undefined;
promptInteractive: string | undefined;
worktree?: string;
yolo: boolean | undefined;
approvalMode: string | undefined;
@@ -115,6 +122,36 @@ const coerceCommaSeparated = (values: string[]): string[] => {
);
};
/**
* Pre-parses the command line arguments to find the worktree flag.
* Used for early setup before full argument parsing with settings.
*/
export function getWorktreeArg(argv: string[]): string | undefined {
const result = yargs(hideBin(argv))
.help(false)
.version(false)
.option('worktree', { alias: 'w', type: 'string' })
.strict(false)
.exitProcess(false)
.parseSync();
if (result.worktree === undefined) return undefined;
return typeof result.worktree === 'string' ? result.worktree.trim() : '';
}
/**
* Checks if a worktree is requested via CLI and enabled in settings.
* Returns the requested name (can be empty string for auto-generated) or undefined.
*/
export function getRequestedWorktreeName(
settings: LoadedSettings,
): string | undefined {
if (!isWorktreeEnabled(settings)) {
return undefined;
}
return getWorktreeArg(process.argv);
}
export async function parseArguments(
settings: MergedSettings,
): Promise<CliArgs> {
@@ -158,6 +195,20 @@ export async function parseArguments(
description:
'Execute the provided prompt and continue in interactive mode',
})
.option('worktree', {
alias: 'w',
type: 'string',
skipValidation: true,
description:
'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.',
coerce: (value: unknown): string => {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (trimmed === '') {
return Math.random().toString(36).substring(2, 10);
}
return trimmed;
},
})
.option('sandbox', {
alias: 's',
type: 'boolean',
@@ -335,6 +386,9 @@ export async function parseArguments(
) {
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
}
if (argv['worktree'] && !settings.experimental?.worktrees) {
return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.';
}
return true;
});
@@ -420,6 +474,7 @@ export interface LoadCliConfigOptions {
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
disabled?: string[];
};
worktreeSettings?: WorktreeSettings;
}
export async function loadCliConfig(
@@ -431,6 +486,9 @@ export async function loadCliConfig(
const { cwd = process.cwd(), projectHooks } = options;
const debugMode = isDebugMode(argv);
const worktreeSettings =
options.worktreeSettings ?? (await resolveWorktreeSettings(cwd));
if (argv.sandbox) {
process.env['GEMINI_SANDBOX'] = 'true';
}
@@ -649,12 +707,16 @@ export async function loadCliConfig(
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive) {
if (!interactive || isAcpMode) {
// The Policy Engine natively handles headless safety by translating ASK_USER
// decisions to DENY. However, we explicitly block ask_user here to guarantee
// it can never be allowed via a high-priority policy rule when no human is present.
// We also exclude it in ACP mode as IDEs intercept tool calls and ask for permission,
// breaking conversational flows.
extraExcludes.push(ASK_USER_TOOL_NAME);
}
@@ -770,7 +832,6 @@ export async function loadCliConfig(
}
}
const isAcpMode = !!argv.acp || !!argv.experimentalAcp;
let clientName: string | undefined = undefined;
if (isAcpMode) {
const ide = detectIdeFromEnv();
@@ -799,6 +860,7 @@ export async function loadCliConfig(
importFormat: settings.context?.importFormat,
debugMode,
question,
worktreeSettings,
coreTools: settings.tools?.core || undefined,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
@@ -940,3 +1002,48 @@ function mergeExcludeTools(
]);
return Array.from(allExcludeTools);
}
async function resolveWorktreeSettings(
cwd: string,
): Promise<WorktreeSettings | undefined> {
let worktreePath: string | undefined;
try {
const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], {
cwd,
});
const toplevel = stdout.trim();
const projectRoot = await getProjectRootForWorktree(toplevel);
if (isGeminiWorktree(toplevel, projectRoot)) {
worktreePath = toplevel;
}
} catch (_e) {
return undefined;
}
if (!worktreePath) {
return undefined;
}
let worktreeBaseSha: string | undefined;
try {
const { stdout } = await execa('git', ['rev-parse', 'HEAD'], {
cwd: worktreePath,
});
worktreeBaseSha = stdout.trim();
} catch (e: unknown) {
debugLogger.debug(
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
);
}
if (!worktreeBaseSha) {
return undefined;
}
return {
name: path.basename(worktreePath),
path: worktreePath,
baseSha: worktreeBaseSha,
};
}
@@ -59,8 +59,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
});
async function expectConsentSnapshot(consentString: string) {
const renderResult = render(React.createElement(Text, null, consentString));
await renderResult.waitUntilReady();
const renderResult = await render(
React.createElement(Text, null, consentString),
);
await expect(renderResult).toMatchSvgSnapshot();
}
+4
View File
@@ -632,6 +632,10 @@ export function resetSettingsCacheForTesting() {
settingsCache.clear();
}
export function isWorktreeEnabled(settings: LoadedSettings): boolean {
return settings.merged.experimental.worktrees;
}
/**
* Loads settings from user and workspace directories.
* Project settings override user settings.
@@ -538,8 +538,32 @@ describe('SettingsSchema', () => {
}
};
const visitJsonSchema = (jsonSchema: Record<string, unknown>) => {
const ref = jsonSchema['ref'];
if (typeof ref === 'string') {
referenced.add(ref);
}
const properties = jsonSchema['properties'];
if (
properties &&
typeof properties === 'object' &&
!Array.isArray(properties)
) {
Object.values(properties as Record<string, unknown>).forEach((prop) =>
visitJsonSchema(prop as Record<string, unknown>),
);
}
const items = jsonSchema['items'];
if (items && typeof items === 'object' && !Array.isArray(items)) {
visitJsonSchema(items as Record<string, unknown>);
}
};
Object.values(schema).forEach(visitDefinition);
// Also visit all definitions to find nested references
Object.values(SETTINGS_SCHEMA_DEFINITIONS).forEach(visitJsonSchema);
// Ensure definitions map doesn't accumulate stale entries.
Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => {
if (!referenced.has(key)) {
+19 -1
View File
@@ -1094,7 +1094,7 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
additionalProperties: {
type: 'array',
ref: 'ModelPolicy',
ref: 'ModelPolicyChain',
},
},
},
@@ -1906,6 +1906,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable local and remote subagents.',
showInDialog: false,
},
worktrees: {
type: 'boolean',
label: 'Enable Git Worktrees',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable automated Git worktree management for parallel work.',
showInDialog: true,
},
extensionManagement: {
type: 'boolean',
label: 'Extension Management',
@@ -2988,6 +2998,14 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
ModelPolicyChain: {
type: 'array',
description: 'A chain of model policies for fallback behavior.',
items: {
type: 'object',
ref: 'ModelPolicy',
},
},
ModelPolicy: {
type: 'object',
description:
+2
View File
@@ -199,6 +199,8 @@ vi.mock('./config/config.js', () => ({
networkAccess: false,
}),
isDebugMode: vi.fn(() => false),
getRequestedWorktreeName: vi.fn(() => undefined),
getWorktreeArg: vi.fn(() => undefined),
}));
vi.mock('read-package-up', () => ({
+10
View File
@@ -9,6 +9,7 @@ import {
WarningPriority,
type Config,
type ResumedSessionData,
type WorktreeInfo,
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
@@ -63,6 +64,7 @@ import {
registerTelemetryConfig,
setupSignalHandlers,
} from './utils/cleanup.js';
import { setupWorktree } from './utils/worktreeSetup.js';
import {
cleanupToolOutputFiles,
cleanupExpiredSessions,
@@ -210,6 +212,13 @@ export async function main() {
const settings = loadSettings();
loadSettingsHandle?.end();
// If a worktree is requested and enabled, set it up early.
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
let worktreeInfo: WorktreeInfo | undefined;
if (requestedWorktree !== undefined) {
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
}
// Report settings errors once during startup
settings.errors.forEach((error) => {
coreEvents.emitFeedback('warning', error.message);
@@ -426,6 +435,7 @@ export async function main() {
const loadConfigHandle = startupProfiler.start('load_cli_config');
const config = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
worktreeSettings: worktreeInfo,
});
loadConfigHandle?.end();
+2
View File
@@ -72,6 +72,8 @@ vi.mock('./config/config.js', () => ({
} as unknown as Config),
parseArguments: vi.fn().mockResolvedValue({}),
isDebugMode: vi.fn(() => false),
getRequestedWorktreeName: vi.fn(() => undefined),
getWorktreeArg: vi.fn(() => undefined),
}));
vi.mock('read-package-up', () => ({
@@ -44,6 +44,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getDeleteSession: vi.fn(() => undefined),
setSessionId: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getWorktreeSettings: vi.fn(() => undefined),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
getAcpMode: vi.fn(() => false),
isBrowserLaunchSuppressed: vi.fn(() => false),
+14 -39
View File
@@ -12,24 +12,18 @@ import { waitFor } from './async.js';
describe('render', () => {
it('should render a component', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<Text>Hello World</Text>,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<Text>Hello World</Text>);
expect(lastFrame()).toBe('Hello World\n');
unmount();
});
it('should support rerender', async () => {
const { lastFrame, rerender, waitUntilReady, unmount } = render(
const { lastFrame, rerender, waitUntilReady, unmount } = await render(
<Text>Hello</Text>,
);
await waitUntilReady();
expect(lastFrame()).toBe('Hello\n');
await act(async () => {
rerender(<Text>World</Text>);
});
await act(async () => rerender(<Text>World</Text>));
await waitUntilReady();
expect(lastFrame()).toBe('World\n');
unmount();
@@ -42,10 +36,8 @@ describe('render', () => {
return <Text>Hello</Text>;
}
const { unmount, waitUntilReady } = render(<TestComponent />);
await waitUntilReady();
const { unmount } = await render(<TestComponent />);
unmount();
expect(cleanupMock).toHaveBeenCalled();
});
});
@@ -54,36 +46,27 @@ describe('renderHook', () => {
it('should rerender with previous props when called without arguments', async () => {
const useTestHook = ({ value }: { value: number }) => {
const [count, setCount] = useState(0);
useEffect(() => {
setCount((c) => c + 1);
}, [value]);
useEffect(() => setCount((c) => c + 1), [value]);
return { count, value };
};
const { result, rerender, waitUntilReady, unmount } = renderHook(
const { result, rerender, waitUntilReady, unmount } = await renderHook(
useTestHook,
{
initialProps: { value: 1 },
},
{ initialProps: { value: 1 } },
);
await waitUntilReady();
expect(result.current.value).toBe(1);
await waitFor(() => expect(result.current.count).toBe(1));
// Rerender with new props
await act(async () => {
rerender({ value: 2 });
});
await act(async () => rerender({ value: 2 }));
await waitUntilReady();
expect(result.current.value).toBe(2);
await waitFor(() => expect(result.current.count).toBe(2));
// Rerender without arguments should use previous props (value: 2)
// This would previously crash or pass undefined if not fixed
await act(async () => {
rerender();
});
await act(async () => rerender());
await waitUntilReady();
expect(result.current.value).toBe(2);
// Count should not increase because value didn't change
@@ -98,14 +81,11 @@ describe('renderHook', () => {
};
const { result, rerender, waitUntilReady, unmount } =
renderHook(useTestHook);
await waitUntilReady();
await renderHook(useTestHook);
expect(result.current.count).toBe(0);
await act(async () => {
rerender();
});
await act(async () => rerender());
await waitUntilReady();
expect(result.current.count).toBe(0);
unmount();
@@ -113,19 +93,14 @@ describe('renderHook', () => {
it('should update props if undefined is passed explicitly', async () => {
const useTestHook = (val: string | undefined) => val;
const { result, rerender, waitUntilReady, unmount } = renderHook(
const { result, rerender, waitUntilReady, unmount } = await renderHook(
useTestHook,
{
initialProps: 'initial' as string | undefined,
},
{ initialProps: 'initial' },
);
await waitUntilReady();
expect(result.current).toBe('initial');
await act(async () => {
rerender(undefined);
});
await act(async () => rerender(undefined));
await waitUntilReady();
expect(result.current).toBeUndefined();
unmount();
+33 -30
View File
@@ -257,13 +257,9 @@ class XtermStdout extends EventEmitter {
return currentFrame !== '';
}
// If both are empty, it's a match.
// We consider undefined lastRenderOutput as effectively empty for this check
// to support hook testing where Ink may skip rendering completely.
if (
(this.lastRenderOutput === undefined || expectedFrame === '') &&
currentFrame === ''
) {
// If Ink expects nothing (no new static content and no dynamic output),
// we consider it a match because the terminal buffer will just hold the historical static content.
if (expectedFrame === '') {
return true;
}
@@ -271,8 +267,8 @@ class XtermStdout extends EventEmitter {
return false;
}
// If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match.
if (expectedFrame === '' || currentFrame === '') {
// If the terminal is empty but Ink expects something, it's not a match.
if (currentFrame === '') {
return false;
}
@@ -382,13 +378,11 @@ export type RenderInstance = {
const instances: InkInstance[] = [];
// Wrapper around ink's render that ensures act() is called and uses Xterm for output
export const render = (
export const render = async (
tree: React.ReactElement,
terminalWidth?: number,
): Omit<
RenderInstance,
'capturedOverflowState' | 'capturedOverflowActions'
): Promise<
Omit<RenderInstance, 'capturedOverflowState' | 'capturedOverflowActions'>
> => {
const cols = terminalWidth ?? 100;
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
@@ -437,6 +431,8 @@ export const render = (
instances.push(instance);
await stdout.waitUntilReady();
return {
rerender: (newTree: React.ReactElement) => {
act(() => {
@@ -751,7 +747,10 @@ export const renderWithProviders = async (
</AppContext.Provider>
);
const renderResult = render(wrapWithProviders(component), terminalWidth);
const renderResult = await render(
wrapWithProviders(component),
terminalWidth,
);
return {
...renderResult,
@@ -765,19 +764,19 @@ export const renderWithProviders = async (
};
};
export function renderHook<Result, Props>(
export async function renderHook<Result, Props>(
renderCallback: (props: Props) => Result,
options?: {
initialProps?: Props;
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
},
): {
): Promise<{
result: { current: Result };
rerender: (props?: Props) => void;
unmount: () => void;
waitUntilReady: () => Promise<void>;
generateSvg: () => string;
} {
}> {
const result = { current: undefined as unknown as Result };
let currentProps = options?.initialProps as Props;
@@ -800,17 +799,15 @@ export function renderHook<Result, Props>(
let waitUntilReady: () => Promise<void> = async () => {};
let generateSvg: () => string = () => '';
act(() => {
const renderResult = render(
<Wrapper>
<TestComponent renderCallback={renderCallback} props={currentProps} />
</Wrapper>,
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
generateSvg = renderResult.generateSvg;
});
const renderResult = await render(
<Wrapper>
<TestComponent renderCallback={renderCallback} props={currentProps} />
</Wrapper>,
);
inkRerender = renderResult.rerender;
unmount = renderResult.unmount;
waitUntilReady = renderResult.waitUntilReady;
generateSvg = renderResult.generateSvg;
function rerender(props?: Props) {
if (arguments.length > 0) {
@@ -864,7 +861,13 @@ export async function renderHookWithProviders<Result, Props>(
const Wrapper = options.wrapper || (({ children }) => <>{children}</>);
let renderResult: ReturnType<typeof render>;
let renderResult: RenderInstance & {
simulateClick: (
col: number,
row: number,
button?: 0 | 1 | 2,
) => Promise<void>;
};
await act(async () => {
renderResult = await renderWithProviders(
+45 -89
View File
@@ -94,14 +94,10 @@ describe('App', () => {
};
it('should render main content and composer when not quitting', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
});
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -115,14 +111,10 @@ describe('App', () => {
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
} as UIState;
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
});
expect(lastFrame()).toContain('Quitting...');
unmount();
@@ -136,14 +128,10 @@ describe('App', () => {
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
} as UIState;
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: quittingUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toContain('HistoryItemDisplay');
expect(lastFrame()).toContain('Quitting...');
@@ -156,14 +144,10 @@ describe('App', () => {
dialogsVisible: true,
} as UIState;
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -183,14 +167,10 @@ describe('App', () => {
[stateKey]: true,
} as UIState;
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
unmount();
@@ -200,14 +180,10 @@ describe('App', () => {
it('should render ScreenReaderAppLayout when screen reader is enabled', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Footer');
@@ -219,14 +195,10 @@ describe('App', () => {
it('should render DefaultAppLayout when screen reader is not enabled', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -274,15 +246,11 @@ describe('App', () => {
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: stateWithConfirmingTool,
config: configWithExperiment,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: stateWithConfirmingTool,
config: configWithExperiment,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications');
@@ -295,28 +263,20 @@ describe('App', () => {
describe('Snapshots', () => {
it('renders default layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders screen reader layout correctly', async () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: mockUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -326,14 +286,10 @@ describe('App', () => {
...mockUIState,
dialogsVisible: true,
} as UIState;
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<App />,
{
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<App />, {
uiState: dialogUIState,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
File diff suppressed because it is too large Load Diff
@@ -53,10 +53,9 @@ describe('IdeIntegrationNudge', () => {
});
it('renders correctly with default options', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<IdeIntegrationNudge {...defaultProps} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?');
@@ -72,8 +71,6 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// "Yes" is the first option and selected by default usually.
await act(async () => {
stdin.write('\r');
@@ -93,8 +90,6 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Navigate down to "No (esc)"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
@@ -119,8 +114,6 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Navigate down to "No, don't ask again"
await act(async () => {
stdin.write('\u001B[B'); // Down arrow
@@ -150,8 +143,6 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
// Press Escape
await act(async () => {
stdin.write('\u001B');
@@ -178,8 +169,6 @@ describe('IdeIntegrationNudge', () => {
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain(
@@ -73,23 +73,21 @@ describe('ApiAuthDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with a defaultValue', async () => {
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
defaultValue="test-key"
/>,
);
await waitUntilReady();
expect(mockedUseTextBuffer).toHaveBeenCalledWith(
expect.objectContaining({
initialText: 'test-key',
@@ -113,10 +111,9 @@ describe('ApiAuthDialog', () => {
'calls $expectedCall.name when $keyName is pressed',
async ({ keyName, sequence, expectedCall, args }) => {
mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
// calls[1] is the TextInput's useKeypress (typing handler)
const keypressHandler = mockedUseKeypress.mock.calls[1][0];
@@ -136,24 +133,22 @@ describe('ApiAuthDialog', () => {
);
it('displays an error message', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ApiAuthDialog
onSubmit={onSubmit}
onCancel={onCancel}
error="Invalid API Key"
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Invalid API Key');
unmount();
});
it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
// Call 0 is ApiAuthDialog (isActive: true)
// Call 1 is TextInput (isActive: true, priority: true)
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
+17 -58
View File
@@ -143,10 +143,9 @@ describe('AuthDialog', () => {
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value as string);
}
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
for (const item of shouldContain) {
expect(items).toContainEqual(item);
@@ -161,10 +160,7 @@ describe('AuthDialog', () => {
it('filters auth types when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(1);
expect(items[0].value).toBe(AuthType.USE_GEMINI);
@@ -173,10 +169,7 @@ describe('AuthDialog', () => {
it('sets initial index to 0 when enforcedType is set', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(initialIndex).toBe(0);
unmount();
@@ -213,10 +206,7 @@ describe('AuthDialog', () => {
},
])('selects initial auth type $desc', async ({ setup, expected }) => {
setup();
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
expect(items[initialIndex].value).toBe(expected);
unmount();
@@ -226,10 +216,7 @@ describe('AuthDialog', () => {
describe('handleAuthSelect', () => {
it('calls onAuthError if validation fails', async () => {
mockedValidateAuthMethod.mockReturnValue('Invalid method');
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
handleAuthSelect(AuthType.USE_GEMINI);
@@ -245,10 +232,7 @@ describe('AuthDialog', () => {
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
@@ -261,10 +245,7 @@ describe('AuthDialog', () => {
it('sets auth context with empty object for other auth types', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -278,10 +259,7 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -297,10 +275,7 @@ describe('AuthDialog', () => {
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -316,10 +291,7 @@ describe('AuthDialog', () => {
// process.env['GEMINI_API_KEY'] is not set
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -337,10 +309,7 @@ describe('AuthDialog', () => {
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await handleAuthSelect(AuthType.USE_GEMINI);
@@ -360,10 +329,7 @@ describe('AuthDialog', () => {
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -383,10 +349,9 @@ describe('AuthDialog', () => {
it('displays authError when provided', async () => {
props.authError = 'Something went wrong';
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Something went wrong');
unmount();
});
@@ -429,10 +394,7 @@ describe('AuthDialog', () => {
},
])('$desc', async ({ setup, expectations }) => {
setup();
const { waitUntilReady, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape' });
expectations(props);
@@ -442,30 +404,27 @@ describe('AuthDialog', () => {
describe('Snapshots', () => {
it('renders correctly with default props', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with auth error', async () => {
props.authError = 'Something went wrong';
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders correctly with enforced auth type', async () => {
props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AuthDialog {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -55,20 +55,18 @@ describe('AuthInProgress', () => {
});
it('renders initial state with spinner', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('[Spinner] Waiting for authentication...');
expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel');
unmount();
});
it('calls onTimeout when ESC is pressed', async () => {
const { waitUntilReady, unmount } = render(
const { waitUntilReady, unmount } = await render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
await act(async () => {
@@ -84,10 +82,9 @@ describe('AuthInProgress', () => {
});
it('calls onTimeout when Ctrl+C is pressed', async () => {
const { waitUntilReady, unmount } = render(
const { waitUntilReady, unmount } = await render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0];
await act(async () => {
@@ -100,10 +97,9 @@ describe('AuthInProgress', () => {
});
it('calls onTimeout and shows timeout message after 3 minutes', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = await render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
await act(async () => {
vi.advanceTimersByTime(180000);
@@ -116,10 +112,7 @@ describe('AuthInProgress', () => {
});
it('clears timer on unmount', async () => {
const { waitUntilReady, unmount } = render(
<AuthInProgress onTimeout={onTimeout} />,
);
await waitUntilReady();
const { unmount } = await render(<AuthInProgress onTimeout={onTimeout} />);
await act(async () => {
unmount();
@@ -73,14 +73,13 @@ describe('BannedAccountDialog', () => {
});
it('renders the suspension message from accountSuspensionInfo', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Account Suspended');
expect(frame).toContain('violation of Terms of Service');
@@ -89,14 +88,13 @@ describe('BannedAccountDialog', () => {
});
it('renders menu options with appeal link text from response', async () => {
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(3);
expect(items[0].label).toBe('Appeal Here');
@@ -109,14 +107,13 @@ describe('BannedAccountDialog', () => {
const infoWithoutUrl: AccountSuspensionInfo = {
message: 'Account suspended.',
};
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutUrl}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items).toHaveLength(2);
expect(items[0].label).toBe('Change authentication');
@@ -129,28 +126,26 @@ describe('BannedAccountDialog', () => {
message: 'Account suspended.',
appealUrl: 'https://example.com/appeal',
};
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={infoWithoutLinkText}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
expect(items[0].label).toBe('Open the Google Form');
unmount();
});
it('opens browser when appeal option is selected', async () => {
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await onSelect('open_form');
expect(mockedOpenBrowser).toHaveBeenCalledWith(
@@ -162,14 +157,13 @@ describe('BannedAccountDialog', () => {
it('shows URL when browser cannot be launched', async () => {
mockedShouldLaunchBrowser.mockReturnValue(false);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
onSelect('open_form');
await waitFor(() => {
@@ -180,14 +174,13 @@ describe('BannedAccountDialog', () => {
});
it('calls onExit when "Exit" is selected', async () => {
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await onSelect('exit');
expect(mockedRunExitCleanup).toHaveBeenCalled();
@@ -196,14 +189,13 @@ describe('BannedAccountDialog', () => {
});
it('calls onChangeAuth when "Change authentication" is selected', async () => {
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
onSelect('change_auth');
expect(onChangeAuth).toHaveBeenCalled();
@@ -212,14 +204,13 @@ describe('BannedAccountDialog', () => {
});
it('exits on escape key', async () => {
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
const result = keypressHandler({ name: 'escape' });
expect(result).toBe(true);
@@ -227,14 +218,13 @@ describe('BannedAccountDialog', () => {
});
it('renders snapshot correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<BannedAccountDialog
accountSuspensionInfo={DEFAULT_SUSPENSION_INFO}
onExit={onExit}
onChangeAuth={onChangeAuth}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -45,25 +45,23 @@ describe('LoginWithGoogleRestartDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('calls onDismiss when escape is pressed', async () => {
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
@@ -83,13 +81,12 @@ describe('LoginWithGoogleRestartDialog', () => {
async (keyName) => {
vi.useFakeTimers();
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<LoginWithGoogleRestartDialog
onDismiss={onDismiss}
config={mockConfig}
/>,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
+113 -90
View File
@@ -4,15 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js';
import {
@@ -22,7 +15,6 @@ import {
} from '@google/gemini-cli-core';
import { AuthState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { waitFor } from '../../test-utils/async.js';
// Mock dependencies
const mockLoadApiKey = vi.fn();
@@ -142,171 +134,202 @@ describe('useAuth', () => {
},
}) as LoadedSettings;
let deferredRefreshAuth: {
resolve: () => void;
reject: (e: Error) => void;
};
beforeEach(() => {
vi.mocked(mockConfig.refreshAuth).mockImplementation(
() =>
new Promise((resolve, reject) => {
deferredRefreshAuth = { resolve, reject };
}),
);
});
it('should initialize with Unauthenticated state', async () => {
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
// Because we defer refreshAuth, the initial state is safely caught here
expect(result.current.authState).toBe(AuthState.Unauthenticated);
await waitFor(() => {
expect(result.current.authState).toBe(AuthState.Authenticated);
await act(async () => {
deferredRefreshAuth.resolve();
});
expect(result.current.authState).toBe(AuthState.Authenticated);
});
it('should set error if no auth type is selected and no env key', async () => {
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useAuthCommand(createSettings(undefined), mockConfig),
);
await waitFor(() => {
expect(result.current.authError).toBe(
'No authentication method selected.',
);
expect(result.current.authState).toBe(AuthState.Updating);
});
// This happens synchronously, no deferred promise
expect(result.current.authError).toBe(
'No authentication method selected.',
);
expect(result.current.authState).toBe(AuthState.Updating);
});
it('should set error if no auth type is selected but env key exists', async () => {
process.env['GEMINI_API_KEY'] = 'env-key';
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useAuthCommand(createSettings(undefined), mockConfig),
);
await waitFor(() => {
expect(result.current.authError).toContain(
'Existing API key detected (GEMINI_API_KEY)',
);
expect(result.current.authState).toBe(AuthState.Updating);
});
expect(result.current.authError).toContain(
'Existing API key detected (GEMINI_API_KEY)',
);
expect(result.current.authState).toBe(AuthState.Updating);
});
it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => {
mockLoadApiKey.mockResolvedValue(null);
const { result } = renderHook(() =>
let deferredLoadKey: { resolve: (k: string | null) => void };
mockLoadApiKey.mockImplementation(
() =>
new Promise((resolve) => {
deferredLoadKey = { resolve };
}),
);
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
);
await waitFor(() => {
expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput);
await act(async () => {
deferredLoadKey.resolve(null);
});
expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput);
});
it('should authenticate if USE_GEMINI and key is found', async () => {
mockLoadApiKey.mockResolvedValue('stored-key');
const { result } = renderHook(() =>
let deferredLoadKey: { resolve: (k: string | null) => void };
mockLoadApiKey.mockImplementation(
() =>
new Promise((resolve) => {
deferredLoadKey = { resolve };
}),
);
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
);
await waitFor(() => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.apiKeyDefaultValue).toBe('stored-key');
await act(async () => {
deferredLoadKey.resolve('stored-key');
});
await act(async () => {
deferredRefreshAuth.resolve();
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.apiKeyDefaultValue).toBe('stored-key');
});
it('should authenticate if USE_GEMINI and env key is found', async () => {
mockLoadApiKey.mockResolvedValue(null);
process.env['GEMINI_API_KEY'] = 'env-key';
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
);
await waitFor(() => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.apiKeyDefaultValue).toBe('env-key');
await act(async () => {
deferredRefreshAuth.resolve();
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.apiKeyDefaultValue).toBe('env-key');
});
it('should prioritize env key over stored key when both are present', async () => {
mockLoadApiKey.mockResolvedValue('stored-key');
process.env['GEMINI_API_KEY'] = 'env-key';
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig),
);
await waitFor(() => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
);
expect(result.current.authState).toBe(AuthState.Authenticated);
// The environment key should take precedence
expect(result.current.apiKeyDefaultValue).toBe('env-key');
await act(async () => {
deferredRefreshAuth.resolve();
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.apiKeyDefaultValue).toBe('env-key');
});
it('should set error if validation fails', async () => {
mockValidateAuthMethod.mockReturnValue('Validation Failed');
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
await waitFor(() => {
expect(result.current.authError).toBe('Validation Failed');
expect(result.current.authState).toBe(AuthState.Updating);
});
expect(result.current.authError).toBe('Validation Failed');
expect(result.current.authState).toBe(AuthState.Updating);
});
it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => {
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE';
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
await waitFor(() => {
expect(result.current.authError).toContain(
'Invalid value for GEMINI_DEFAULT_AUTH_TYPE',
);
expect(result.current.authState).toBe(AuthState.Updating);
});
expect(result.current.authError).toContain(
'Invalid value for GEMINI_DEFAULT_AUTH_TYPE',
);
expect(result.current.authState).toBe(AuthState.Updating);
});
it('should authenticate successfully for valid auth type', async () => {
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
await waitFor(() => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.authError).toBeNull();
await act(async () => {
deferredRefreshAuth.resolve();
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(result.current.authState).toBe(AuthState.Authenticated);
expect(result.current.authError).toBeNull();
});
it('should handle refreshAuth failure', async () => {
(mockConfig.refreshAuth as Mock).mockRejectedValue(
new Error('Auth Failed'),
);
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
await waitFor(() => {
expect(result.current.authError).toContain('Failed to sign in');
expect(result.current.authState).toBe(AuthState.Updating);
await act(async () => {
deferredRefreshAuth.reject(new Error('Auth Failed'));
});
expect(result.current.authError).toContain('Failed to sign in');
expect(result.current.authState).toBe(AuthState.Updating);
});
it('should handle ProjectIdRequiredError without "Failed to login" prefix', async () => {
const projectIdError = new ProjectIdRequiredError();
(mockConfig.refreshAuth as Mock).mockRejectedValue(projectIdError);
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
await waitFor(() => {
expect(result.current.authError).toBe(
'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca',
);
expect(result.current.authError).not.toContain('Failed to login');
expect(result.current.authState).toBe(AuthState.Updating);
await act(async () => {
deferredRefreshAuth.reject(projectIdError);
});
expect(result.current.authError).toBe(
'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca',
);
expect(result.current.authError).not.toContain('Failed to login');
expect(result.current.authState).toBe(AuthState.Updating);
});
});
});
@@ -710,10 +710,14 @@ describe('extensionsCommand', () => {
size: 100,
} as Stats);
await linkAction!(mockContext, packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'link',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: packageName,
type: 'link',
},
undefined,
undefined,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.INFO,
text: `Linking extension from "${packageName}"...`,
@@ -733,10 +737,14 @@ describe('extensionsCommand', () => {
} as Stats);
await linkAction!(mockContext, packageName);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: packageName,
type: 'link',
});
expect(mockInstallExtension).toHaveBeenCalledWith(
{
source: packageName,
type: 'link',
},
undefined,
undefined,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith({
type: MessageType.ERROR,
text: `Failed to link extension from "${packageName}": ${errorMessage}`,
@@ -286,6 +286,11 @@ async function exploreAction(
await installAction(context, extension.url, requestConsentOverride);
context.ui.removeComponent();
},
onLink: async (extension, requestConsentOverride) => {
debugLogger.log(`Linking extension: ${extension.extensionName}`);
await linkAction(context, extension.url, requestConsentOverride);
context.ui.removeComponent();
},
onClose: () => context.ui.removeComponent(),
extensionManager,
}),
@@ -533,7 +538,11 @@ async function installAction(
}
}
async function linkAction(context: CommandContext, args: string) {
async function linkAction(
context: CommandContext,
args: string,
requestConsentOverride?: (consent: string) => Promise<boolean>,
) {
const extensionLoader =
context.services.agentContext?.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
@@ -582,8 +591,11 @@ async function linkAction(context: CommandContext, args: string) {
source: sourceFilepath,
type: 'link',
};
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
const extension = await extensionLoader.installOrUpdateExtension(
installMetadata,
undefined,
requestConsentOverride,
);
context.ui.addItem({
type: MessageType.INFO,
text: `Extension "${extension.name}" linked successfully.`,
@@ -25,10 +25,9 @@ describe('AboutBox', () => {
};
it('renders with required props', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AboutBox {...defaultProps} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('About Gemini CLI');
expect(output).toContain('1.0.0');
@@ -46,10 +45,9 @@ describe('AboutBox', () => {
['tier', 'Enterprise', 'Tier'],
])('renders optional prop %s', async (prop, value, label) => {
const props = { ...defaultProps, [prop]: value };
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(label);
expect(output).toContain(value);
@@ -58,10 +56,9 @@ describe('AboutBox', () => {
it('renders Auth Method with email when userEmail is provided', async () => {
const props = { ...defaultProps, userEmail: 'test@example.com' };
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Signed in with Google (test@example.com)');
unmount();
@@ -69,10 +66,9 @@ describe('AboutBox', () => {
it('renders Auth Method correctly when not oauth', async () => {
const props = { ...defaultProps, selectedAuthType: 'api-key' };
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AboutBox {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('api-key');
unmount();
@@ -17,15 +17,14 @@ describe('AdminSettingsChangedDialog', () => {
});
it('renders correctly', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AdminSettingsChangedDialog />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('restarts on "r" key press', async () => {
const { stdin, waitUntilReady } = await renderWithProviders(
const { stdin } = await renderWithProviders(
<AdminSettingsChangedDialog />,
{
uiActions: {
@@ -33,7 +32,6 @@ describe('AdminSettingsChangedDialog', () => {
},
},
);
await waitUntilReady();
act(() => {
stdin.write('r');
@@ -43,7 +41,7 @@ describe('AdminSettingsChangedDialog', () => {
});
it.each(['r', 'R'])('restarts on "%s" key press', async (key) => {
const { stdin, waitUntilReady } = await renderWithProviders(
const { stdin } = await renderWithProviders(
<AdminSettingsChangedDialog />,
{
uiActions: {
@@ -51,7 +49,6 @@ describe('AdminSettingsChangedDialog', () => {
},
},
);
await waitUntilReady();
act(() => {
stdin.write(key);
@@ -126,7 +126,6 @@ describe('AgentConfigDialog', () => {
/>,
{ settings, uiState: { mainAreaWidth: 100 } },
);
await result.waitUntilReady();
return result;
};
@@ -108,7 +108,7 @@ describe('AlternateBufferQuittingDisplay', () => {
it('renders with active and pending tool messages', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -118,14 +118,13 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_history_and_pending');
unmount();
});
it('renders with empty history and no pending items', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -135,14 +134,13 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('empty');
unmount();
});
it('renders with history but no pending items', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -152,14 +150,13 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_history_no_pending');
unmount();
});
it('renders with pending items but no history', async () => {
persistentStateMock.setData({ tipsShown: 0 });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -169,7 +166,6 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_pending_no_history');
unmount();
});
@@ -195,7 +191,7 @@ describe('AlternateBufferQuittingDisplay', () => {
],
},
];
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -205,7 +201,6 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Action Required (was prompted):');
expect(output).toContain('confirming_tool');
@@ -220,7 +215,7 @@ describe('AlternateBufferQuittingDisplay', () => {
{ id: 1, type: 'user', text: 'Hello Gemini' },
{ id: 2, type: 'gemini', text: 'Hello User!' },
];
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
@@ -230,7 +225,6 @@ describe('AlternateBufferQuittingDisplay', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');
unmount();
});
@@ -29,10 +29,9 @@ describe('<AnsiOutputText />', () => {
createAnsiToken({ text: 'world!' }),
],
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame().trim()).toBe('Hello, world!');
unmount();
});
@@ -47,10 +46,9 @@ describe('<AnsiOutputText />', () => {
{ style: { inverse: true }, text: 'Inverse' },
])('correctly applies style $text', async ({ style, text }) => {
const data: AnsiOutput = [[createAnsiToken({ text, ...style })]];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame().trim()).toBe(text);
unmount();
});
@@ -61,10 +59,9 @@ describe('<AnsiOutputText />', () => {
{ color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' },
])('correctly applies color $text', async ({ color, text }) => {
const data: AnsiOutput = [[createAnsiToken({ text, ...color })]];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
expect(lastFrame().trim()).toBe(text);
unmount();
});
@@ -76,10 +73,9 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Third line' })],
[createAnsiToken({ text: '' })],
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={data} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
const lines = output.split('\n');
@@ -96,10 +92,9 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={data} availableTerminalHeight={2} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
@@ -115,10 +110,9 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={data} maxLines={2} width={80} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
@@ -135,7 +129,7 @@ describe('<AnsiOutputText />', () => {
[createAnsiToken({ text: 'Line 4' })],
];
// availableTerminalHeight=3, maxLines=2 => show 2 lines
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText
data={data}
availableTerminalHeight={3}
@@ -143,7 +137,6 @@ describe('<AnsiOutputText />', () => {
width={80}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
@@ -156,10 +149,9 @@ describe('<AnsiOutputText />', () => {
for (let i = 0; i < 1000; i++) {
largeData.push([createAnsiToken({ text: `Line ${i}` })]);
}
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<AnsiOutputText data={largeData} width={80} />,
);
await waitUntilReady();
// We are just checking that it renders something without crashing.
expect(lastFrame()).toBeDefined();
unmount();
@@ -27,13 +27,12 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
@@ -50,13 +49,12 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('There are capacity issues');
expect(lastFrame()).toMatchSnapshot();
@@ -72,13 +70,12 @@ describe('<AppHeader />', () => {
},
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Banner');
expect(lastFrame()).toMatchSnapshot();
@@ -103,13 +100,12 @@ describe('<AppHeader />', () => {
},
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('This is the default banner');
expect(lastFrame()).toMatchSnapshot();
@@ -129,13 +125,12 @@ describe('<AppHeader />', () => {
// and interfering with the expected persistentState.set call.
persistentStateMock.setData({ tipsShown: 10 });
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(persistentStateMock.set).toHaveBeenCalledWith(
'defaultBannerShownCount',
@@ -159,13 +154,12 @@ describe('<AppHeader />', () => {
bannerVisible: true,
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('First line\\nSecond line');
unmount();
@@ -183,13 +177,12 @@ describe('<AppHeader />', () => {
persistentStateMock.setData({ tipsShown: 5 });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tips');
expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6);
@@ -206,13 +199,12 @@ describe('<AppHeader />', () => {
persistentStateMock.setData({ tipsShown: 10 });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Tips');
unmount();
@@ -234,7 +226,6 @@ describe('<AppHeader />', () => {
const session1 = await renderWithProviders(<AppHeader version="1.0.0" />, {
uiState,
});
await session1.waitUntilReady();
expect(session1.lastFrame()).toContain('Tips');
expect(persistentStateMock.get('tipsShown')).toBe(10);
@@ -245,7 +236,6 @@ describe('<AppHeader />', () => {
<AppHeader version="1.0.0" />,
{},
);
await session2.waitUntilReady();
expect(session2.lastFrame()).not.toContain('Tips');
session2.unmount();
@@ -11,56 +11,50 @@ import { ApprovalMode } from '@google/gemini-cli-core';
describe('ApprovalModeIndicator', () => {
it('renders correctly for AUTO_EDIT mode', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for AUTO_EDIT mode with plan enabled', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.AUTO_EDIT}
allowPlanMode={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for PLAN mode', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for YOLO mode', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly for DEFAULT mode with plan enabled', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<ApprovalModeIndicator
approvalMode={ApprovalMode.DEFAULT}
allowPlanMode={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -48,7 +48,7 @@ describe('AskUserDialog', () => {
];
it('renders question and options', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -58,7 +58,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -397,7 +396,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -407,12 +406,11 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('hides progress header for single question', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -422,12 +420,11 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('shows keyboard hints', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={authQuestion}
onSubmit={vi.fn()}
@@ -437,7 +434,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -471,7 +467,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toContain('Which testing framework?');
writeKey(stdin, '\x1b[C'); // Right arrow
@@ -582,7 +577,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={multiQuestions}
onSubmit={vi.fn()}
@@ -592,7 +587,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -736,7 +730,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -746,7 +740,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -759,7 +752,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -769,7 +762,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -820,7 +812,7 @@ describe('AskUserDialog', () => {
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<AskUserDialog
questions={textQuestion}
onSubmit={vi.fn()}
@@ -830,7 +822,6 @@ describe('AskUserDialog', () => {
{ width: 120 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -145,7 +145,7 @@ describe('<BackgroundShellDisplay />', () => {
it('renders the output of the active shell', async () => {
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -158,7 +158,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -166,7 +165,7 @@ describe('<BackgroundShellDisplay />', () => {
it('renders tabs for multiple shells', async () => {
const width = 100;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -179,7 +178,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -187,7 +185,7 @@ describe('<BackgroundShellDisplay />', () => {
it('highlights the focused state', async () => {
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -200,7 +198,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -208,7 +205,7 @@ describe('<BackgroundShellDisplay />', () => {
it('resizes the PTY on mount and when dimensions change', async () => {
const width = 80;
const { rerender, waitUntilReady, unmount } = render(
const { rerender, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -221,7 +218,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
@@ -241,7 +237,6 @@ describe('<BackgroundShellDisplay />', () => {
/>
</ScrollProvider>,
);
await waitUntilReady();
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
@@ -253,7 +248,7 @@ describe('<BackgroundShellDisplay />', () => {
it('renders the process list when isListOpenProp is true', async () => {
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -266,7 +261,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -274,7 +268,7 @@ describe('<BackgroundShellDisplay />', () => {
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
const width = 80;
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -287,19 +281,16 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
await act(async () => {
simulateKey({ name: 'down' });
});
await waitUntilReady();
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
await act(async () => {
simulateKey({ name: 'l', ctrl: true });
});
await waitUntilReady();
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
@@ -308,7 +299,7 @@ describe('<BackgroundShellDisplay />', () => {
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
const width = 80;
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -321,7 +312,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
// Initial state: shell1 (active) is highlighted
@@ -329,13 +319,11 @@ describe('<BackgroundShellDisplay />', () => {
await act(async () => {
simulateKey({ name: 'down' });
});
await waitUntilReady();
// Press Ctrl+K
await act(async () => {
simulateKey({ name: 'k', ctrl: true });
});
await waitUntilReady();
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
unmount();
@@ -343,7 +331,7 @@ describe('<BackgroundShellDisplay />', () => {
it('kills the active process when Ctrl+K is pressed in output view', async () => {
const width = 80;
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -356,12 +344,10 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
await act(async () => {
simulateKey({ name: 'k', ctrl: true });
});
await waitUntilReady();
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
unmount();
@@ -370,7 +356,7 @@ describe('<BackgroundShellDisplay />', () => {
it('scrolls to active shell when list opens', async () => {
// shell2 is active
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -383,7 +369,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -402,7 +387,7 @@ describe('<BackgroundShellDisplay />', () => {
mockShells.set(exitedShell.pid, exitedShell);
const width = 80;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
@@ -415,7 +400,6 @@ describe('<BackgroundShellDisplay />', () => {
</ScrollProvider>,
width,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -18,10 +18,9 @@ describe('<Checklist />', () => {
];
it('renders nothing when list is empty', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Checklist title="Test List" items={[]} isExpanded={true} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
@@ -30,15 +29,14 @@ describe('<Checklist />', () => {
{ status: 'completed', label: 'Task 1' },
{ status: 'cancelled', label: 'Task 2' },
];
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Checklist title="Test List" items={inactiveItems} isExpanded={false} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders summary view correctly (collapsed)', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Checklist
title="Test List"
items={items}
@@ -46,12 +44,11 @@ describe('<Checklist />', () => {
toggleHint="toggle me"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('renders expanded view correctly', async () => {
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Checklist
title="Test List"
items={items}
@@ -59,7 +56,6 @@ describe('<Checklist />', () => {
toggleHint="toggle me"
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -68,10 +64,9 @@ describe('<Checklist />', () => {
{ status: 'completed', label: 'Task 1' },
{ status: 'pending', label: 'Task 2' },
];
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Checklist title="Test List" items={pendingItems} isExpanded={false} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -17,8 +17,7 @@ describe('<ChecklistItem />', () => {
{ status: 'cancelled', label: 'Skipped this' },
{ status: 'blocked', label: 'Blocked this' },
] as ChecklistItemData[])('renders %s item correctly', async (item) => {
const { lastFrame, waitUntilReady } = render(<ChecklistItem item={item} />);
await waitUntilReady();
const { lastFrame } = await render(<ChecklistItem item={item} />);
expect(lastFrame()).toMatchSnapshot();
});
@@ -28,12 +27,11 @@ describe('<ChecklistItem />', () => {
label:
'This is a very long text that should be truncated because the wrap prop is set to truncate',
};
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Box width={30}>
<ChecklistItem item={item} wrap="truncate" />
</Box>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
@@ -43,12 +41,11 @@ describe('<ChecklistItem />', () => {
label:
'This is a very long text that should wrap because the default behavior is wrapping',
};
const { lastFrame, waitUntilReady } = render(
const { lastFrame } = await render(
<Box width={30}>
<ChecklistItem item={item} />
</Box>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -17,10 +17,7 @@ describe('<CliSpinner />', () => {
it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => {
expect(debugState.debugNumAnimatedComponents).toBe(0);
const { waitUntilReady, unmount } = await renderWithProviders(
<CliSpinner />,
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<CliSpinner />);
expect(debugState.debugNumAnimatedComponents).toBe(1);
unmount();
expect(debugState.debugNumAnimatedComponents).toBe(0);
@@ -28,11 +25,9 @@ describe('<CliSpinner />', () => {
it('should not render when showSpinner is false', async () => {
const settings = createMockSettings({ ui: { showSpinner: false } });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<CliSpinner />,
{ settings },
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<CliSpinner />, {
settings,
});
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -96,10 +96,9 @@ describe('ColorsDisplay', () => {
it('renders correctly', async () => {
const mockTheme = themeManager.getActiveTheme();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<ColorsDisplay activeTheme={mockTheme} />,
);
await waitUntilReady();
const output = lastFrame();
// Check for title and description
@@ -251,7 +251,7 @@ const renderComposer = async (
config = createMockConfig(),
uiActions = createMockUIActions(),
) => {
const result = render(
const result = await render(
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
@@ -262,7 +262,6 @@ const renderComposer = async (
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
await result.waitUntilReady();
// Wait for shortcuts hint debounce if using fake timers
if (vi.isFakeTimers()) {
@@ -43,10 +43,7 @@ describe('ConfigInitDisplay', () => {
});
it('renders initial state', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ConfigInitDisplay />,
);
await waitUntilReady();
const { lastFrame } = await renderWithProviders(<ConfigInitDisplay />);
expect(lastFrame()).toMatchSnapshot();
});
@@ -33,14 +33,13 @@ describe('ConsentPrompt', () => {
it('renders a string prompt with MarkdownDisplay', async () => {
const prompt = 'Are you sure?';
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedMarkdownDisplay).toHaveBeenCalledWith(
{
@@ -55,14 +54,13 @@ describe('ConsentPrompt', () => {
it('renders a ReactNode prompt directly', async () => {
const prompt = <Text>Are you sure?</Text>;
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedMarkdownDisplay).not.toHaveBeenCalled();
expect(lastFrame()).toContain('Are you sure?');
@@ -71,14 +69,13 @@ describe('ConsentPrompt', () => {
it('calls onConfirm with true when "Yes" is selected', async () => {
const prompt = 'Are you sure?';
const { waitUntilReady, unmount } = render(
const { waitUntilReady, unmount } = await render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
@@ -92,14 +89,13 @@ describe('ConsentPrompt', () => {
it('calls onConfirm with false when "No" is selected', async () => {
const prompt = 'Are you sure?';
const { waitUntilReady, unmount } = render(
const { waitUntilReady, unmount } = await render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect;
await act(async () => {
@@ -113,14 +109,13 @@ describe('ConsentPrompt', () => {
it('passes correct items to RadioButtonSelect', async () => {
const prompt = 'Are you sure?';
const { waitUntilReady, unmount } = render(
const { unmount } = await render(
<ConsentPrompt
prompt={prompt}
onConfirm={onConfirm}
terminalWidth={terminalWidth}
/>,
);
await waitUntilReady();
expect(MockedRadioButtonSelect).toHaveBeenCalledWith(
expect.objectContaining({
@@ -10,10 +10,9 @@ import { describe, it, expect } from 'vitest';
describe('ConsoleSummaryDisplay', () => {
it('renders nothing when errorCount is 0', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ConsoleSummaryDisplay errorCount={0} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -22,10 +21,9 @@ describe('ConsoleSummaryDisplay', () => {
[1, '1 error'],
[5, '5 errors'],
])('renders correct message for %i errors', async (count, expectedText) => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ConsoleSummaryDisplay errorCount={count} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain(expectedText);
expect(output).toContain('✖');
@@ -26,8 +26,7 @@ const renderWithWidth = async (
props: React.ComponentProps<typeof ContextSummaryDisplay>,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
const result = render(<ContextSummaryDisplay {...props} />);
await result.waitUntilReady();
const result = await render(<ContextSummaryDisplay {...props} />);
return result;
};
@@ -19,35 +19,33 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
describe('ContextUsageDisplay', () => {
it('renders correct percentage used', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<ContextUsageDisplay
promptTokenCount={5000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('50% used');
unmount();
});
it('renders correctly when usage is 0%', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<ContextUsageDisplay
promptTokenCount={0}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% used');
unmount();
});
it('renders abbreviated label when terminal width is small', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<ContextUsageDisplay
promptTokenCount={2000}
model="gemini-pro"
@@ -55,7 +53,6 @@ describe('ContextUsageDisplay', () => {
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('20%');
expect(output).not.toContain('context used');
@@ -63,28 +60,26 @@ describe('ContextUsageDisplay', () => {
});
it('renders 80% correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<ContextUsageDisplay
promptTokenCount={8000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80% used');
unmount();
});
it('renders 100% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<ContextUsageDisplay
promptTokenCount={10000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('100% used');
unmount();
@@ -22,8 +22,7 @@ describe('CopyModeWarning', () => {
mockUseUIState.mockReturnValue({
copyModeEnabled: false,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<CopyModeWarning />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -32,8 +31,7 @@ describe('CopyModeWarning', () => {
mockUseUIState.mockReturnValue({
copyModeEnabled: true,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<CopyModeWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<CopyModeWarning />);
expect(lastFrame()).toContain('In Copy Mode');
expect(lastFrame()).toContain('Use Page Up/Down to scroll');
expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit');
@@ -242,8 +242,7 @@ describe('DebugProfiler Component', () => {
showDebugProfiler: false,
constrainHeight: false,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<DebugProfiler />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -257,8 +256,7 @@ describe('DebugProfiler Component', () => {
profiler.totalIdleFrames = 5;
profiler.totalFlickerFrames = 2;
const { lastFrame, waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<DebugProfiler />);
const output = lastFrame();
expect(output).toContain('Renders: 10 (total)');
@@ -275,8 +273,7 @@ describe('DebugProfiler Component', () => {
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
const { waitUntilReady, unmount } = await render(<DebugProfiler />);
await act(async () => {
coreEvents.emitModelChanged('new-model');
@@ -295,8 +292,7 @@ describe('DebugProfiler Component', () => {
const reportActionSpy = vi.spyOn(profiler, 'reportAction');
const { waitUntilReady, unmount } = render(<DebugProfiler />);
await waitUntilReady();
const { waitUntilReady, unmount } = await render(<DebugProfiler />);
await act(async () => {
appEvents.emit(AppEvent.SelectionWarning);
@@ -41,13 +41,12 @@ describe('DetailedMessagesDisplay', () => {
});
});
it('renders nothing when messages are empty', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -64,13 +63,12 @@ describe('DetailedMessagesDisplay', () => {
clearConsoleMessages: vi.fn(),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
@@ -86,13 +84,12 @@ describe('DetailedMessagesDisplay', () => {
clearConsoleMessages: vi.fn(),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
@@ -106,13 +103,12 @@ describe('DetailedMessagesDisplay', () => {
clearConsoleMessages: vi.fn(),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
@@ -126,13 +122,12 @@ describe('DetailedMessagesDisplay', () => {
clearConsoleMessages: vi.fn(),
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
@@ -104,11 +104,10 @@ describe('DialogManager', () => {
};
it('renders nothing by default', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<DialogManager {...defaultProps} />,
{ uiState: baseUiState as Partial<UIState> as UIState },
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -197,7 +196,7 @@ describe('DialogManager', () => {
it.each(testCases)(
'renders %s when state is %o',
async (uiStateOverride, expectedComponent) => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<DialogManager {...defaultProps} />,
{
uiState: {
@@ -206,7 +205,6 @@ describe('DialogManager', () => {
} as Partial<UIState> as UIState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain(expectedComponent);
unmount();
},
@@ -55,27 +55,25 @@ describe('EditorSettingsDialog', () => {
renderWithProviders(ui);
it('renders correctly', async () => {
const { lastFrame, waitUntilReady } = await renderWithProvider(
const { lastFrame } = await renderWithProvider(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={mockSettings}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('calls onSelect when an editor is selected', async () => {
const onSelect = vi.fn();
const { lastFrame, waitUntilReady } = await renderWithProvider(
const { lastFrame } = await renderWithProvider(
<EditorSettingsDialog
onSelect={onSelect}
settings={mockSettings}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('VS Code');
});
@@ -88,7 +86,6 @@ describe('EditorSettingsDialog', () => {
onExit={vi.fn()}
/>,
);
await waitUntilReady();
// Initial focus on editor
expect(lastFrame()).toContain('> Select Editor');
@@ -134,7 +131,6 @@ describe('EditorSettingsDialog', () => {
onExit={onExit}
/>,
);
await waitUntilReady();
await act(async () => {
stdin.write('\u001B'); // Escape
@@ -162,14 +158,13 @@ describe('EditorSettingsDialog', () => {
},
} as unknown as LoadedSettings;
const { lastFrame, waitUntilReady } = await renderWithProvider(
const { lastFrame } = await renderWithProvider(
<EditorSettingsDialog
onSelect={vi.fn()}
settings={settingsWithOtherScope}
onExit={vi.fn()}
/>,
);
await waitUntilReady();
const frame = lastFrame() || '';
if (!frame.includes('(Also modified')) {
@@ -30,7 +30,7 @@ describe('EmptyWalletDialog', () => {
describe('rendering', () => {
it('should match snapshot with fallback available', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
@@ -38,33 +38,30 @@ describe('EmptyWalletDialog', () => {
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should match snapshot without fallback', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should display the model name and usage limit message', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('gemini-2.5-pro');
@@ -73,13 +70,12 @@ describe('EmptyWalletDialog', () => {
});
it('should display purchase prompt and credits update notice', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('purchase more AI Credits');
@@ -90,14 +86,13 @@ describe('EmptyWalletDialog', () => {
});
it('should display reset time when provided', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
resetTime="3:45 PM"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('3:45 PM');
@@ -106,13 +101,12 @@ describe('EmptyWalletDialog', () => {
});
it('should not display reset time when not provided', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).not.toContain('Access resets at');
@@ -120,13 +114,12 @@ describe('EmptyWalletDialog', () => {
});
it('should display slash command hints', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('/stats');
@@ -139,14 +132,13 @@ describe('EmptyWalletDialog', () => {
describe('onChoice handling', () => {
it('should call onGetCredits and onChoice when get_credits is selected', async () => {
// get_credits is the first item, so just press Enter
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
const { unmount, stdin } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
onGetCredits={mockOnGetCredits}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
@@ -158,13 +150,12 @@ describe('EmptyWalletDialog', () => {
});
it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => {
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
const { unmount, stdin } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
@@ -177,14 +168,13 @@ describe('EmptyWalletDialog', () => {
it('should call onChoice with use_fallback when selected', async () => {
// With fallback: items are [get_credits, use_fallback, stop]
// use_fallback is the second item: Down + Enter
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
const { unmount, stdin } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
@@ -198,13 +188,12 @@ describe('EmptyWalletDialog', () => {
it('should call onChoice with stop when selected', async () => {
// Without fallback: items are [get_credits, stop]
// stop is the second item: Down + Enter
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
const { unmount, stdin } = await renderWithProviders(
<EmptyWalletDialog
failedModel="gemini-2.5-pro"
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
@@ -440,36 +440,38 @@ Implement a comprehensive authentication system with multiple providers.
return <>{children}</>;
};
const { stdin, lastFrame } = await renderWithProviders(
<BubbleListener>
<ExitPlanModeDialog
planPath={mockPlanFullPath}
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
/>
</BubbleListener>,
{
config: {
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
storage: {
getPlansDir: () => mockPlansDir,
},
getFileSystemService: (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
const { stdin, lastFrame } = await act(async () =>
renderWithProviders(
<BubbleListener>
<ExitPlanModeDialog
planPath={mockPlanFullPath}
onApprove={onApprove}
onFeedback={onFeedback}
onCancel={onCancel}
getPreferredEditor={vi.fn()}
width={80}
availableHeight={24}
/>
</BubbleListener>,
{
config: {
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
isTrustedFolder: () => true,
storage: {
getPlansDir: () => mockPlansDir,
},
getFileSystemService: (): FileSystemService => ({
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
}),
},
},
),
);
await act(async () => {
@@ -24,8 +24,7 @@ describe('ExitWarning', () => {
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<ExitWarning />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -36,8 +35,7 @@ describe('ExitWarning', () => {
ctrlCPressedOnce: true,
ctrlDPressedOnce: false,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<ExitWarning />);
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
unmount();
});
@@ -48,8 +46,7 @@ describe('ExitWarning', () => {
ctrlCPressedOnce: false,
ctrlDPressedOnce: true,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<ExitWarning />);
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
unmount();
});
@@ -60,8 +57,7 @@ describe('ExitWarning', () => {
ctrlCPressedOnce: true,
ctrlDPressedOnce: true,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<ExitWarning />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -48,10 +48,9 @@ describe('FolderTrustDialog', () => {
});
it('should render the dialog with title and description', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Do you trust the files in this folder?');
expect(lastFrame()).toContain(
@@ -72,7 +71,7 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -85,7 +84,6 @@ describe('FolderTrustDialog', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('This folder contains:');
expect(lastFrame()).toContain('hidden');
unmount();
@@ -103,7 +101,7 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -116,7 +114,6 @@ describe('FolderTrustDialog', () => {
},
);
await waitUntilReady();
// With maxHeight=4, the intro text (4 lines) will take most of the space.
// The discovery results will likely be hidden.
expect(lastFrame()).toContain('hidden');
@@ -135,7 +132,7 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -148,7 +145,6 @@ describe('FolderTrustDialog', () => {
},
);
await waitUntilReady();
expect(lastFrame()).toContain('hidden');
unmount();
});
@@ -182,9 +178,7 @@ describe('FolderTrustDialog', () => {
// Initial state: truncated
await waitFor(() => {
expect(lastFrame()).toContain('Do you trust the files in this folder?');
// In standard terminal mode, the expansion hint is handled globally by ToastDisplay
// via AppContainer, so it should not be present in the dialog's local frame.
expect(lastFrame()).not.toContain('Press Ctrl+O');
expect(lastFrame()).toContain('Press Ctrl+O');
expect(lastFrame()).toContain('hidden');
});
@@ -221,7 +215,6 @@ describe('FolderTrustDialog', () => {
await renderWithProviders(
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
);
await waitUntilReady();
await act(async () => {
stdin.write('\u001b[27u'); // Press kitty escape key
@@ -246,10 +239,9 @@ describe('FolderTrustDialog', () => {
});
it('should display restart message when isRestarting is true', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Gemini CLI is restarting');
unmount();
@@ -260,10 +252,9 @@ describe('FolderTrustDialog', () => {
const relaunchApp = vi
.spyOn(processUtils, 'relaunchApp')
.mockResolvedValue(undefined);
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await waitUntilReady();
await vi.advanceTimersByTimeAsync(250);
expect(relaunchApp).toHaveBeenCalled();
unmount();
@@ -275,10 +266,9 @@ describe('FolderTrustDialog', () => {
const relaunchApp = vi
.spyOn(processUtils, 'relaunchApp')
.mockResolvedValue(undefined);
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
);
await waitUntilReady();
// Unmount immediately (before 250ms)
unmount();
@@ -292,7 +282,6 @@ describe('FolderTrustDialog', () => {
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
);
await waitUntilReady();
await act(async () => {
stdin.write('r');
@@ -308,30 +297,27 @@ describe('FolderTrustDialog', () => {
describe('directory display', () => {
it('should correctly display the folder name for a nested directory', async () => {
mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Trust folder (project)');
unmount();
});
it('should correctly display the parent folder name for a nested directory', async () => {
mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Trust parent folder (user)');
unmount();
});
it('should correctly display an empty parent folder name for a directory directly under root', async () => {
mockedCwd.mockReturnValue('/project');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Trust parent folder ()');
unmount();
});
@@ -348,7 +334,7 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -356,7 +342,6 @@ describe('FolderTrustDialog', () => {
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toContain('This folder contains:');
expect(lastFrame()).toContain('• Commands (2):');
expect(lastFrame()).toContain('- cmd1');
@@ -386,14 +371,13 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: ['Dangerous setting detected!'],
};
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Security Warnings:');
expect(lastFrame()).toContain('Dangerous setting detected!');
unmount();
@@ -410,14 +394,13 @@ describe('FolderTrustDialog', () => {
discoveryErrors: ['Failed to load custom commands'],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Discovery Errors:');
expect(lastFrame()).toContain('Failed to load custom commands');
unmount();
@@ -434,7 +417,7 @@ describe('FolderTrustDialog', () => {
discoveryErrors: [],
securityWarnings: [],
};
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -447,7 +430,6 @@ describe('FolderTrustDialog', () => {
},
);
await waitUntilReady();
// In alternate buffer + expanded, the title should be visible (StickyHeader)
expect(lastFrame()).toContain('Do you trust the files in this folder?');
// And it should NOT use MaxSizedBox truncation
@@ -470,7 +452,7 @@ describe('FolderTrustDialog', () => {
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
};
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<FolderTrustDialog
onSelect={vi.fn()}
discoveryResults={discoveryResults}
@@ -478,7 +460,6 @@ describe('FolderTrustDialog', () => {
{ width: 100, uiState: { terminalHeight: 40 } },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('cmd-with-ansi');
+304 -424
View File
@@ -138,33 +138,25 @@ describe('<Footer />', () => {
});
it('renders the component', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
});
expect(lastFrame()).toBeDefined();
unmount();
});
describe('path display', () => {
it('should display a shortened path on a narrow terminal', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
});
const output = lastFrame();
expect(output).toBeDefined();
// Should contain some part of the path, likely shortened
@@ -173,15 +165,11 @@ describe('<Footer />', () => {
});
it('should use wide layout at 80 columns', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 80,
uiState: { sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 80,
uiState: { sessionStats: mockSessionStats },
});
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain(path.join('make', 'it'));
@@ -189,28 +177,24 @@ describe('<Footer />', () => {
});
it('should not truncate high-priority items on narrow terminals (regression)', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 60,
uiState: {
sessionStats: mockSessionStats,
},
settings: createMockSettings({
general: {
vimMode: true,
},
ui: {
footer: {
showLabels: true,
items: ['workspace', 'model-name'],
},
},
}),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 60,
uiState: {
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
settings: createMockSettings({
general: {
vimMode: true,
},
ui: {
footer: {
showLabels: true,
items: ['workspace', 'model-name'],
},
},
}),
});
const output = lastFrame();
// [INSERT] is high priority and should be fully visible
// (Note: VimModeProvider defaults to 'INSERT' mode when enabled)
@@ -222,168 +206,140 @@ describe('<Footer />', () => {
});
it('displays the branch name when provided', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
branchName: defaultProps.branchName,
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
});
expect(lastFrame()).toContain(defaultProps.branchName);
unmount();
});
it('does not display the branch name when not provided', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { branchName: undefined, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { branchName: undefined, sessionStats: mockSessionStats },
});
expect(lastFrame()).not.toContain('Branch');
unmount();
});
it('displays the model name and context percentage', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
currentModel: defaultProps.model,
sessionStats: {
...mockSessionStats,
lastPromptTokenCount: 1000,
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
currentModel: defaultProps.model,
sessionStats: {
...mockSessionStats,
lastPromptTokenCount: 1000,
},
},
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
},
}),
},
);
await waitUntilReady();
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% used/);
unmount();
});
it('displays the usage indicator when usage is low', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
);
await waitUntilReady();
});
expect(lastFrame()).toContain('85%');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
it('hides the usage indicator when usage is not near limit', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
);
await waitUntilReady();
});
expect(normalizeFrame(lastFrame())).not.toContain('used');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
it('displays "Limit reached" message when remaining is 0', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
overageMenuRequest: null,
emptyWalletRequest: null,
},
},
);
await waitUntilReady();
});
expect(lastFrame()?.toLowerCase()).toContain('limit reached');
expect(normalizeFrame(lastFrame())).toMatchSnapshot();
unmount();
});
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 99,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 99,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+%/);
expect(lastFrame()).not.toContain('context used');
@@ -392,33 +348,25 @@ describe('<Footer />', () => {
describe('sandbox and trust info', () => {
it('should display untrusted when isTrustedFolder is false', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain('untrusted');
unmount();
});
it('should display custom sandbox info when SANDBOX env is set', async () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
isTrustedFolder: undefined,
sessionStats: mockSessionStats,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
isTrustedFolder: undefined,
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
});
expect(lastFrame()).toContain('test');
vi.unstubAllEnvs();
unmount();
@@ -427,15 +375,11 @@ describe('<Footer />', () => {
it('should display macOS Seatbelt info when SANDBOX is sandbox-exec', async () => {
vi.stubEnv('SANDBOX', 'sandbox-exec');
vi.stubEnv('SEATBELT_PROFILE', 'test-profile');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
});
expect(lastFrame()).toMatch(/macOS Seatbelt.*\(test-profile\)/s);
vi.unstubAllEnvs();
unmount();
@@ -444,15 +388,11 @@ describe('<Footer />', () => {
it('should display "no sandbox" when SANDBOX is not set and folder is trusted', async () => {
// Clear any SANDBOX env var that might be set.
vi.stubEnv('SANDBOX', '');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: true, sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain('no sandbox');
vi.unstubAllEnvs();
unmount();
@@ -460,15 +400,11 @@ describe('<Footer />', () => {
it('should prioritize untrusted message over sandbox info', async () => {
vi.stubEnv('SANDBOX', 'gemini-cli-test-sandbox');
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { isTrustedFolder: false, sessionStats: mockSessionStats },
});
expect(lastFrame()).toContain('untrusted');
expect(lastFrame()).not.toMatch(/test-sandbox/s);
vi.unstubAllEnvs();
@@ -478,22 +414,18 @@ describe('<Footer />', () => {
describe('footer configuration filtering (golden snapshots)', () => {
it('renders complete footer with all sections visible (baseline)', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'complete-footer-wide',
);
@@ -523,47 +455,39 @@ describe('<Footer />', () => {
});
it('renders footer with only model info hidden (partial filtering)', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: false,
hideSandboxStatus: false,
hideModelInfo: true,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(normalizeFrame(lastFrame())).toMatchSnapshot('footer-no-model');
unmount();
});
it('renders footer with CWD and model info hidden to test alignment (only sandbox visible)', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: false,
hideModelInfo: true,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'footer-only-sandbox',
);
@@ -571,64 +495,52 @@ describe('<Footer />', () => {
});
it('hides the context percentage when hideContextPercentage is true', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: true,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: true,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% used/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% used/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 79,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideContextPercentage: false,
},
}),
},
);
await waitUntilReady();
},
}),
});
expect(normalizeFrame(lastFrame())).toMatchSnapshot(
'complete-footer-narrow',
);
@@ -714,60 +626,48 @@ describe('<Footer />', () => {
});
it('hides error summary in low verbosity mode out of dev mode', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
);
await waitUntilReady();
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
});
expect(lastFrame()).not.toContain('F12 for details');
unmount();
});
it('shows error summary in low verbosity mode in dev mode', async () => {
mocks.isDevelopment = true;
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
);
await waitUntilReady();
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
});
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
});
it('shows error summary in full verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
);
await waitUntilReady();
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
});
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
@@ -776,25 +676,21 @@ describe('<Footer />', () => {
describe('Footer Custom Items', () => {
it('renders items in the specified order', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
},
settings: createMockSettings({
ui: {
footer: {
items: ['model-name', 'workspace'],
},
},
}),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
settings: createMockSettings({
ui: {
footer: {
items: ['model-name', 'workspace'],
},
},
}),
});
const output = lastFrame();
const modelIdx = output.indexOf('/model');
@@ -804,28 +700,24 @@ describe('<Footer />', () => {
});
it('renders multiple items with proper alignment', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: 'main',
},
settings: createMockSettings({
vimMode: {
vimMode: true,
},
ui: {
footer: {
items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
},
},
}),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: 'main',
},
);
await waitUntilReady();
settings: createMockSettings({
vimMode: {
vimMode: true,
},
ui: {
footer: {
items: ['workspace', 'git-branch', 'sandbox', 'model-name'],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
@@ -862,25 +754,21 @@ describe('<Footer />', () => {
});
it('does not render items that are conditionally hidden', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: undefined, // No branch
},
settings: createMockSettings({
ui: {
footer: {
items: ['workspace', 'git-branch', 'model-name'],
},
},
}),
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
branchName: undefined, // No branch
},
);
await waitUntilReady();
settings: createMockSettings({
ui: {
footer: {
items: ['workspace', 'git-branch', 'model-name'],
},
},
}),
});
const output = lastFrame();
expect(output).toBeDefined();
@@ -893,18 +781,14 @@ describe('<Footer />', () => {
describe('fallback mode display', () => {
it('should display Flash model when in fallback mode, not the configured Pro model', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-flash', // Fallback active, showing Flash
},
);
await waitUntilReady();
});
// Footer should show the effective model (Flash), not the config model (Pro)
expect(lastFrame()).toContain('gemini-2.5-flash');
@@ -913,18 +797,14 @@ describe('<Footer />', () => {
});
it('should display Pro model when NOT in fallback mode', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: mockSessionStats,
currentModel: 'gemini-2.5-pro', // Normal mode, showing Pro
},
);
await waitUntilReady();
});
expect(lastFrame()).toContain('gemini-2.5-pro');
unmount();
@@ -30,19 +30,17 @@ describe('<FooterConfigDialog />', () => {
{ settings },
);
await renderResult.waitUntilReady();
expect(renderResult.lastFrame()).toMatchSnapshot();
await expect(renderResult).toMatchSvgSnapshot();
});
it('toggles an item when enter is pressed', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
const { lastFrame, stdin } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
act(() => {
stdin.write('\r'); // Enter to toggle
});
@@ -62,12 +60,11 @@ describe('<FooterConfigDialog />', () => {
it('reorders items with arrow keys', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
const { lastFrame, stdin } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Initial order: workspace, git-branch, ...
const output = lastFrame();
const cwdIdx = output.indexOf('] workspace');
@@ -93,12 +90,11 @@ describe('<FooterConfigDialog />', () => {
it('closes on Esc', async () => {
const settings = createMockSettings();
const { stdin, waitUntilReady } = await renderWithProviders(
const { stdin } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
act(() => {
stdin.write('\x1b'); // Esc
});
@@ -115,9 +111,8 @@ describe('<FooterConfigDialog />', () => {
{ settings },
);
const { lastFrame, stdin, waitUntilReady } = renderResult;
const { lastFrame, stdin } = renderResult;
await waitUntilReady();
expect(lastFrame()).toContain('~/project/path');
// Move focus down to 'code-changes' (which has colored elements)
@@ -148,13 +143,11 @@ describe('<FooterConfigDialog />', () => {
it('shows an empty preview when all items are deselected', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
const { lastFrame, stdin } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Default items are the first 5. We toggle them off.
for (let i = 0; i < 5; i++) {
act(() => {
@@ -178,11 +171,10 @@ describe('<FooterConfigDialog />', () => {
it('moves item correctly after trying to move up at the top', async () => {
const settings = createMockSettings();
const { lastFrame, stdin, waitUntilReady } = await renderWithProviders(
const { lastFrame, stdin } = await renderWithProviders(
<FooterConfigDialog onClose={mockOnClose} />,
{ settings },
);
await waitUntilReady();
// Default initial items in mock settings are 'git-branch', 'workspace', ...
await waitFor(() => {
@@ -222,8 +214,7 @@ describe('<FooterConfigDialog />', () => {
{ settings },
);
const { lastFrame, stdin, waitUntilReady } = renderResult;
await waitUntilReady();
const { lastFrame, stdin } = renderResult;
// By default labels are on
expect(lastFrame()).toContain('workspace (/directory)');
@@ -41,10 +41,7 @@ describe('GeminiRespondingSpinner', () => {
it('renders spinner when responding', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame()).toContain('GeminiSpinner');
unmount();
});
@@ -52,30 +49,23 @@ describe('GeminiRespondingSpinner', () => {
it('renders screen reader text when responding and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
unmount();
});
it('renders nothing when not responding and no non-responding display', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, waitUntilReady, unmount } = render(
<GeminiRespondingSpinner />,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders non-responding display when provided', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Waiting...');
unmount();
});
@@ -83,10 +73,9 @@ describe('GeminiRespondingSpinner', () => {
it('renders screen reader loading text when non-responding display provided and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
await waitUntilReady();
expect(lastFrame()).toContain(SCREEN_READER_LOADING);
unmount();
});
@@ -72,53 +72,46 @@ useSessionStatsMock.mockReturnValue({
describe('Gradient Crash Regression Tests', () => {
it('<Header /> 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} />,
{
width: 120,
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
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 () => {}} />,
{
width: 120,
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
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} />,
{
width: 120,
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
unmount();
});
it('<Footer /> should not crash when theme.ui.gradient has only one color (or empty) and nightly is true', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
nightly: true, // Enable nightly to trigger Gradient usage logic
sessionStats: mockSessionStats,
},
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
width: 120,
uiState: {
nightly: true, // Enable nightly to trigger Gradient usage logic
sessionStats: mockSessionStats,
},
);
await waitUntilReady();
});
// If it crashes, this line won't be reached or lastFrame() will throw
expect(lastFrame()).toBeDefined();
// It should fall back to rendering text without gradient
@@ -127,7 +120,7 @@ describe('Gradient Crash Regression Tests', () => {
});
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" />,
{
width: 120,
@@ -136,7 +129,6 @@ describe('Gradient Crash Regression Tests', () => {
},
},
);
await waitUntilReady();
expect(lastFrame()).toBeDefined();
// Ensure title is rendered
expect(lastFrame()).toContain('My Stats');
+11 -11
View File
@@ -39,12 +39,12 @@ describe('<Header />', () => {
vi.clearAllMocks();
});
it('renders the long logo on a wide terminal', () => {
it('renders the long logo on a wide terminal', async () => {
vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({
columns: 120,
rows: 20,
});
render(<Header version="1.0.0" nightly={false} />);
await render(<Header version="1.0.0" nightly={false} />);
expect(Text).toHaveBeenCalledWith(
expect.objectContaining({
children: longAsciiLogo,
@@ -53,9 +53,9 @@ describe('<Header />', () => {
);
});
it('renders custom ASCII art when provided', () => {
it('renders custom ASCII art when provided', async () => {
const customArt = 'CUSTOM ART';
render(
await render(
<Header version="1.0.0" nightly={false} customAsciiArt={customArt} />,
);
expect(Text).toHaveBeenCalledWith(
@@ -66,8 +66,8 @@ describe('<Header />', () => {
);
});
it('displays the version number when nightly is true', () => {
render(<Header version="1.0.0" nightly={true} />);
it('displays the version number when nightly is true', async () => {
await render(<Header version="1.0.0" nightly={true} />);
const textCalls = (Text as Mock).mock.calls;
const versionText = Array.isArray(textCalls[1][0].children)
? textCalls[1][0].children.join('')
@@ -75,8 +75,8 @@ describe('<Header />', () => {
expect(versionText).toBe('v1.0.0');
});
it('does not display the version number when nightly is false', () => {
render(<Header version="1.0.0" nightly={false} />);
it('does not display the version number when nightly is false', async () => {
await render(<Header version="1.0.0" nightly={false} />);
expect(Text).not.toHaveBeenCalledWith(
expect.objectContaining({
children: 'v1.0.0',
@@ -119,7 +119,7 @@ describe('<Header />', () => {
},
});
const Gradient = await import('ink-gradient');
render(<Header version="1.0.0" nightly={false} />);
await render(<Header version="1.0.0" nightly={false} />);
expect(Gradient.default).not.toHaveBeenCalled();
const textCalls = (Text as Mock).mock.calls;
expect(textCalls[0][0]).toHaveProperty('color', '#123456');
@@ -131,7 +131,7 @@ describe('<Header />', () => {
ui: { gradient: [singleColor] },
} as typeof semanticColors.theme);
const Gradient = await import('ink-gradient');
render(<Header version="1.0.0" nightly={false} />);
await render(<Header version="1.0.0" nightly={false} />);
expect(Gradient.default).not.toHaveBeenCalled();
const textCalls = (Text as Mock).mock.calls;
expect(textCalls.length).toBe(1);
@@ -144,7 +144,7 @@ describe('<Header />', () => {
ui: { gradient: gradientColors },
} as typeof semanticColors.theme);
const Gradient = await import('ink-gradient');
render(<Header version="1.0.0" nightly={false} />);
await render(<Header version="1.0.0" nightly={false} />);
expect(Gradient.default).toHaveBeenCalledWith(
expect.objectContaining({
colors: gradientColors,
+3 -6
View File
@@ -43,10 +43,9 @@ const mockCommands: readonly SlashCommand[] = [
describe('Help Component', () => {
it('should not render hidden commands', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<Help commands={mockCommands} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('/test');
@@ -55,10 +54,9 @@ describe('Help Component', () => {
});
it('should not render hidden subcommands', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<Help commands={mockCommands} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('visible-child');
@@ -67,10 +65,9 @@ describe('Help Component', () => {
});
it('should render keyboard shortcuts', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<Help commands={mockCommands} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Keyboard Shortcuts:');
@@ -39,10 +39,9 @@ describe('<HistoryItemDisplay />', () => {
type: MessageType.USER,
text: 'Hello',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Hello');
unmount();
});
@@ -53,10 +52,9 @@ describe('<HistoryItemDisplay />', () => {
type: 'hint',
text: 'Try using ripgrep first',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Try using ripgrep first');
unmount();
});
@@ -67,10 +65,9 @@ describe('<HistoryItemDisplay />', () => {
type: MessageType.USER,
text: '/theme',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('/theme');
unmount();
});
@@ -83,14 +80,13 @@ describe('<HistoryItemDisplay />', () => {
type: MessageType.INFO,
text: '⚡ Line 1\n⚡ Line 2\n⚡ Line 3',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
},
@@ -114,10 +110,9 @@ describe('<HistoryItemDisplay />', () => {
},
],
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -128,12 +123,11 @@ describe('<HistoryItemDisplay />', () => {
type: MessageType.STATS,
duration: '1s',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Stats');
unmount();
});
@@ -150,10 +144,9 @@ describe('<HistoryItemDisplay />', () => {
gcpProject: 'test-project',
ideClient: 'test-ide',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('About Gemini CLI');
unmount();
});
@@ -163,12 +156,11 @@ describe('<HistoryItemDisplay />', () => {
...baseItem,
type: 'model_stats',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'No API calls have been made in this session.',
);
@@ -180,12 +172,11 @@ describe('<HistoryItemDisplay />', () => {
...baseItem,
type: 'tool_stats',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'No tool calls have been made in this session.',
);
@@ -198,12 +189,11 @@ describe('<HistoryItemDisplay />', () => {
type: 'quit',
duration: '1s',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Agent powering down. Goodbye!');
unmount();
});
@@ -215,14 +205,13 @@ describe('<HistoryItemDisplay />', () => {
text: 'Hello, \u001b[31mred\u001b[0m world!',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay
item={historyItem}
terminalWidth={80}
isPending={false}
/>,
);
await waitUntilReady();
// The ANSI codes should be escaped for display.
expect(lastFrame()).toContain('Hello, \\u001b[31mred\\u001b[0m world!');
@@ -253,14 +242,13 @@ describe('<HistoryItemDisplay />', () => {
],
};
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<HistoryItemDisplay
item={historyItem}
terminalWidth={80}
isPending={false}
/>,
);
await waitUntilReady();
const passedProps = vi.mocked(ToolGroupMessage).mock.calls[0][0];
const confirmationDetails = passedProps.toolCalls[0]
@@ -279,13 +267,12 @@ describe('<HistoryItemDisplay />', () => {
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
settings: createMockSettings({ ui: { inlineThinkingMode: 'full' } }),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -297,13 +284,12 @@ describe('<HistoryItemDisplay />', () => {
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} isFirstThinking={true} />,
{
settings: createMockSettings({ ui: { inlineThinkingMode: 'full' } }),
},
);
await waitUntilReady();
expect(lastFrame()).toContain(' Thinking...');
expect(lastFrame()).toMatchSnapshot();
@@ -315,13 +301,12 @@ describe('<HistoryItemDisplay />', () => {
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
settings: createMockSettings({ ui: { inlineThinkingMode: 'off' } }),
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
@@ -343,21 +328,18 @@ describe('<HistoryItemDisplay />', () => {
type: 'gemini',
text: longCode,
};
const { lastFrame, waitUntilReady, unmount } =
await renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -368,22 +350,19 @@ describe('<HistoryItemDisplay />', () => {
type: 'gemini',
text: longCode,
};
const { lastFrame, waitUntilReady, unmount } =
await renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -394,21 +373,18 @@ describe('<HistoryItemDisplay />', () => {
type: 'gemini_content',
text: longCode,
};
const { lastFrame, waitUntilReady, unmount } =
await renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -419,22 +395,19 @@ describe('<HistoryItemDisplay />', () => {
type: 'gemini_content',
text: longCode,
};
const { lastFrame, waitUntilReady, unmount } =
await renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -18,10 +18,9 @@ describe('<HookStatusDisplay />', () => {
const props = {
activeHooks: [{ name: 'test-hook', eventName: 'BeforeAgent' }],
};
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -33,10 +32,9 @@ describe('<HookStatusDisplay />', () => {
{ name: 'h2', eventName: 'BeforeAgent' },
],
};
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -47,20 +45,18 @@ describe('<HookStatusDisplay />', () => {
{ name: 'step', eventName: 'BeforeAgent', index: 1, total: 3 },
],
};
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should return empty string if no active hooks', async () => {
const props = { activeHooks: [] };
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -35,20 +35,18 @@ describe('HooksDialog', () => {
describe('snapshots', () => {
it('renders empty hooks dialog', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HooksDialog hooks={[]} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders single hook with security warning, source, and tips', async () => {
const hooks = [createMockHook('test-hook', 'before-tool', true)];
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -59,10 +57,9 @@ describe('HooksDialog', () => {
createMockHook('hook2', 'before-tool', false),
createMockHook('hook3', 'after-agent', true),
];
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -80,10 +77,9 @@ describe('HooksDialog', () => {
},
}),
];
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -100,10 +96,9 @@ describe('HooksDialog', () => {
enabled: true,
},
];
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -112,10 +107,9 @@ describe('HooksDialog', () => {
describe('keyboard interaction', () => {
it('should call onClose when escape key is pressed', async () => {
const onClose = vi.fn();
const { waitUntilReady, stdin, unmount } = await renderWithProviders(
const { stdin, unmount } = await renderWithProviders(
<HooksDialog hooks={[]} onClose={onClose} />,
);
await waitUntilReady();
act(() => {
stdin.write('\u001b[27u');
@@ -137,10 +131,9 @@ describe('HooksDialog', () => {
createMockHook('hook1', 'before-tool', true),
createMockHook('hook2', 'after-tool', false),
];
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={10} />,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('▲');
expect(lastFrame()).not.toContain('▼');
@@ -149,10 +142,9 @@ describe('HooksDialog', () => {
it('should show scroll down indicator when there are more hooks than maxVisibleHooks', async () => {
const hooks = createManyHooks(15);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('▼');
unmount();
@@ -164,7 +156,6 @@ describe('HooksDialog', () => {
await renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Initially should not show up indicator
expect(lastFrame()).not.toContain('▲');
@@ -185,7 +176,6 @@ describe('HooksDialog', () => {
await renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Scroll down twice
act(() => {
@@ -213,7 +203,6 @@ describe('HooksDialog', () => {
await renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Scroll down many times past the end
act(() => {
@@ -236,7 +225,6 @@ describe('HooksDialog', () => {
await renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Try to scroll up when already at top
act(() => {
@@ -17,10 +17,9 @@ describe('IdeTrustChangeDialog', () => {
});
it('renders the correct message for CONNECTION_CHANGE', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
);
await waitUntilReady();
const frameText = lastFrame();
expect(frameText).toContain(
@@ -31,10 +30,9 @@ describe('IdeTrustChangeDialog', () => {
});
it('renders the correct message for TRUST_CHANGE', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<IdeTrustChangeDialog reason="TRUST_CHANGE" />,
);
await waitUntilReady();
const frameText = lastFrame();
expect(frameText).toContain(
@@ -48,10 +46,9 @@ describe('IdeTrustChangeDialog', () => {
const debugLoggerWarnSpy = vi
.spyOn(debugLogger, 'warn')
.mockImplementation(() => {});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<IdeTrustChangeDialog reason="NONE" />,
);
await waitUntilReady();
const frameText = lastFrame();
expect(frameText).toContain('Workspace trust has changed.');
@@ -68,7 +65,6 @@ describe('IdeTrustChangeDialog', () => {
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
<IdeTrustChangeDialog reason="NONE" />,
);
await waitUntilReady();
await act(async () => {
stdin.write('r');
@@ -86,7 +82,6 @@ describe('IdeTrustChangeDialog', () => {
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
);
await waitUntilReady();
await act(async () => {
stdin.write('R');
@@ -104,7 +99,6 @@ describe('IdeTrustChangeDialog', () => {
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
<IdeTrustChangeDialog reason="CONNECTION_CHANGE" />,
);
await waitUntilReady();
await act(async () => {
stdin.write('a');
@@ -55,20 +55,18 @@ describe('<LoadingIndicator />', () => {
};
it('should render blank when streamingState is Idle and no loading phrase or thought', async () => {
const { lastFrame, waitUntilReady } = await renderWithContext(
const { lastFrame } = await renderWithContext(
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
});
it('should render spinner, phrase, and time when streamingState is Responding', async () => {
const { lastFrame, waitUntilReady } = await renderWithContext(
const { lastFrame } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
@@ -80,11 +78,10 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
};
const { lastFrame, waitUntilReady } = await renderWithContext(
const { lastFrame } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
@@ -97,11 +94,10 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Processing data...',
elapsedTime: 3,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Processing data...');
unmount();
});
@@ -111,11 +107,10 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Working...',
elapsedTime: 60,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 1m)');
unmount();
});
@@ -125,22 +120,20 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Working...',
elapsedTime: 125,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
unmount();
});
it('should render rightContent when provided', async () => {
const rightContent = <Text>Extra Info</Text>;
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Extra Info');
unmount();
});
@@ -181,7 +174,6 @@ describe('<LoadingIndicator />', () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<TestWrapper />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding
@@ -232,11 +224,10 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Loading...',
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Loading...');
unmount();
@@ -250,11 +241,10 @@ describe('<LoadingIndicator />', () => {
},
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
@@ -274,11 +264,10 @@ describe('<LoadingIndicator />', () => {
},
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking... Planning the response...');
unmount();
@@ -293,11 +282,10 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'This should not be displayed',
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking... ');
expect(output).toContain('This should be displayed');
@@ -306,20 +294,19 @@ describe('<LoadingIndicator />', () => {
});
it('should not display thought indicator for non-thought loading phrases', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator
currentLoadingPhrase="some random tip..."
elapsedTime={3}
/>,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Thinking... ');
unmount();
});
it('should truncate long primary text instead of wrapping', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
currentLoadingPhrase={
@@ -329,7 +316,6 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -337,7 +323,7 @@ describe('<LoadingIndicator />', () => {
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
@@ -345,7 +331,6 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
120,
);
await waitUntilReady();
const output = lastFrame();
// Check for single line output
expect(output?.trim().includes('\n')).toBe(false);
@@ -356,7 +341,7 @@ describe('<LoadingIndicator />', () => {
});
it('should render on multiple lines on a narrow terminal', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
@@ -364,7 +349,6 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
79,
);
await waitUntilReady();
const output = lastFrame();
const lines = output?.trim().split('\n');
// Expecting 3 lines:
@@ -382,23 +366,21 @@ describe('<LoadingIndicator />', () => {
});
it('should use wide layout at 80 columns', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()?.trim().includes('\n')).toBe(false);
unmount();
});
it('should use narrow layout at 79 columns', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
const { lastFrame, unmount } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
await waitUntilReady();
expect(lastFrame()?.includes('\n')).toBe(true);
unmount();
});
@@ -23,10 +23,9 @@ describe('LogoutConfirmationDialog', () => {
});
it('should render the dialog with title, description, and hint', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<LogoutConfirmationDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('You are now signed out');
expect(lastFrame()).toContain(
@@ -37,10 +36,9 @@ describe('LogoutConfirmationDialog', () => {
});
it('should render RadioButtonSelect with Login and Exit options', async () => {
const { waitUntilReady, unmount } = await renderWithProviders(
const { unmount } = await renderWithProviders(
<LogoutConfirmationDialog onSelect={vi.fn()} />,
);
await waitUntilReady();
expect(RadioButtonSelect).toHaveBeenCalled();
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
@@ -57,7 +55,6 @@ describe('LogoutConfirmationDialog', () => {
const { waitUntilReady, unmount } = await renderWithProviders(
<LogoutConfirmationDialog onSelect={onSelect} />,
);
await waitUntilReady();
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
await act(async () => {
@@ -74,7 +71,6 @@ describe('LogoutConfirmationDialog', () => {
const { waitUntilReady, unmount } = await renderWithProviders(
<LogoutConfirmationDialog onSelect={onSelect} />,
);
await waitUntilReady();
const mockCall = vi.mocked(RadioButtonSelect).mock.calls[0][0];
await act(async () => {
@@ -91,7 +87,6 @@ describe('LogoutConfirmationDialog', () => {
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
<LogoutConfirmationDialog onSelect={onSelect} />,
);
await waitUntilReady();
await act(async () => {
// Send kitty escape key sequence
@@ -12,21 +12,19 @@ describe('LoopDetectionConfirmation', () => {
const onComplete = vi.fn();
it('renders correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<LoopDetectionConfirmation onComplete={onComplete} />,
{ width: 101 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('contains the expected options', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<LoopDetectionConfirmation onComplete={onComplete} />,
{ width: 100 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('A potential loop was detected');
@@ -364,14 +364,9 @@ describe('MainContent', () => {
it('renders in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<MainContent />,
{
uiState: defaultMockUiState as Partial<UIState>,
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
const output = lastFrame();
expect(output).toContain('AppHeader(full)');
expect(output).toContain('Hello');
@@ -452,14 +447,9 @@ describe('MainContent', () => {
it('does not constrain height in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<MainContent />,
{
uiState: defaultMockUiState as Partial<UIState>,
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
const output = lastFrame();
expect(output).toContain('AppHeader(full)');
expect(output).toContain('Hello');
@@ -479,16 +469,11 @@ describe('MainContent', () => {
staticAreaMaxItemHeight: 5,
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<MainContent />,
{
uiState: uiState as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const output = lastFrame();
expect(output).toMatchSnapshot();
@@ -507,16 +492,11 @@ describe('MainContent', () => {
staticAreaMaxItemHeight: 5,
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<MainContent />,
{
uiState: uiState as unknown as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
uiState: uiState as unknown as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
});
const output = lastFrame();
expect(output).toMatchSnapshot();
@@ -564,14 +544,9 @@ describe('MainContent', () => {
],
};
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<MainContent />,
{
uiState: uiState as Partial<UIState>,
},
);
await waitUntilReady();
const { lastFrame, unmount } = await renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>,
});
const output = lastFrame();
// Verify Part 1 and Part 2 are rendered.
expect(output).toContain('Part 1');
@@ -629,7 +604,6 @@ describe('MainContent', () => {
const renderResult = await renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>,
});
await renderResult.waitUntilReady();
const output = renderResult.lastFrame();
expect(output).toContain('Initial analysis');
@@ -732,15 +706,16 @@ describe('MainContent', () => {
bannerVisible: false,
};
const { lastFrame, waitUntilReady, unmount } =
await renderWithProviders(<MainContent />, {
const { lastFrame, unmount } = await renderWithProviders(
<MainContent />,
{
uiState: uiState as Partial<UIState>,
config: makeFakeConfig({ useAlternateBuffer: isAlternateBuffer }),
settings: createMockSettings({
ui: { useAlternateBuffer: isAlternateBuffer },
}),
});
await waitUntilReady();
},
);
const output = lastFrame();
@@ -30,19 +30,15 @@ describe('MemoryUsageDisplay', () => {
});
it('renders memory usage', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<MemoryUsageDisplay />,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<MemoryUsageDisplay />);
expect(lastFrame()).toContain('50.0 MB');
unmount();
});
it('updates memory usage over time', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = await render(
<MemoryUsageDisplay />,
);
await waitUntilReady();
expect(lastFrame()).toContain('50.0 MB');
vi.mocked(process.memoryUsage).mockReturnValue({
@@ -115,7 +115,6 @@ describe('<ModelDialog />', () => {
settings,
},
);
await result.waitUntilReady();
return result;
};
@@ -233,7 +233,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
});
// Deduplicate: only show one entry per unique resolved model value.
// This is needed because 3 pro and 3.1 pro models can resolve to the same value.
// This is needed because 3 pro and 3.1 pro models can resolve to the same
// value, depending on the useGemini31 flag.
const seen = new Set<string>();
return list.filter((option) => {
if (seen.has(option.value)) return false;
@@ -59,11 +59,10 @@ const renderWithMockedStats = async (
},
} as unknown as LoadedSettings);
const result = render(
const result = await render(
<ModelStatsDisplay currentModel={currentModel} />,
width,
);
await result.waitUntilReady();
return result;
};
@@ -529,14 +528,13 @@ describe('<ModelStatsDisplay />', () => {
startNewPrompt: vi.fn(),
});
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<ModelStatsDisplay
selectedAuthType="oauth"
userEmail="test@example.com"
tier="Pro"
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Auth Method:');
@@ -73,10 +73,9 @@ describe('MultiFolderTrustDialog', () => {
it('renders the dialog with the list of folders', async () => {
const folders = ['/path/to/folder1', '/path/to/folder2'];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
await waitUntilReady();
expect(lastFrame()).toContain(
'Do you trust the following folders being added to this workspace?',
@@ -88,10 +87,9 @@ describe('MultiFolderTrustDialog', () => {
it('calls onComplete and finishAddingDirectories with an error on escape', async () => {
const folders = ['/path/to/folder1'];
const { waitUntilReady, unmount } = render(
const { waitUntilReady, unmount } = await render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
await waitUntilReady();
const keypressCallback = mockedUseKeypress.mock.calls[0][0];
await act(async () => {
@@ -121,10 +119,9 @@ describe('MultiFolderTrustDialog', () => {
it('calls finishAddingDirectories with an error and does not add directories when "No" is chosen', async () => {
const folders = ['/path/to/folder1'];
const { waitUntilReady, unmount } = render(
const { waitUntilReady, unmount } = await render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -148,14 +145,13 @@ describe('MultiFolderTrustDialog', () => {
it('adds directories to workspace context when "Yes" is chosen', async () => {
const folders = ['/path/to/folder1', '/path/to/folder2'];
const { waitUntilReady, unmount } = render(
const { waitUntilReady, unmount } = await render(
<MultiFolderTrustDialog
{...defaultProps}
folders={folders}
trustedDirs={['/already/trusted']}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -182,10 +178,9 @@ describe('MultiFolderTrustDialog', () => {
it('adds directories to workspace context and remembers them as trusted when "Yes, and remember" is chosen', async () => {
const folders = ['/path/to/folder1'];
const { waitUntilReady, unmount } = render(
const { waitUntilReady, unmount } = await render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -212,10 +207,9 @@ describe('MultiFolderTrustDialog', () => {
it('shows submitting message after a choice is made', async () => {
const folders = ['/path/to/folder1'];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = await render(
<MultiFolderTrustDialog {...defaultProps} folders={folders} />,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
@@ -230,14 +224,13 @@ describe('MultiFolderTrustDialog', () => {
it('shows an error message and completes when config is missing', async () => {
const folders = ['/path/to/folder1'];
const { waitUntilReady, unmount } = render(
const { waitUntilReady, unmount } = await render(
<MultiFolderTrustDialog
{...defaultProps}
folders={folders}
config={null as unknown as Config}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -263,14 +256,13 @@ describe('MultiFolderTrustDialog', () => {
});
const folders = ['/path/to/good', '/path/to/error'];
const { waitUntilReady, unmount } = render(
const { waitUntilReady, unmount } = await render(
<MultiFolderTrustDialog
{...defaultProps}
folders={folders}
errors={['initial error']}
/>,
);
await waitUntilReady();
const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0];
await act(async () => {
@@ -49,10 +49,9 @@ describe('NewAgentsNotification', () => {
const onSelect = vi.fn();
it('renders agent list', async () => {
const { lastFrame, waitUntilReady, unmount } = await render(
const { lastFrame, unmount } = await render(
<NewAgentsNotification agents={mockAgents} onSelect={onSelect} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toMatchSnapshot();
@@ -68,10 +67,9 @@ describe('NewAgentsNotification', () => {
inputConfig: { inputSchema: {} },
}));
const { lastFrame, waitUntilReady, unmount } = await render(
const { lastFrame, unmount } = await render(
<NewAgentsNotification agents={manyAgents} onSelect={onSelect} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toMatchSnapshot();
@@ -111,14 +111,13 @@ describe('Notifications', () => {
});
it('renders nothing when no notifications', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<Notifications />,
{
settings,
width: 100,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -137,7 +136,7 @@ describe('Notifications', () => {
version: '1.0.0',
} as AppState;
mockUseAppContext.mockReturnValue(appState);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<Notifications />,
{
appState,
@@ -145,7 +144,6 @@ describe('Notifications', () => {
width: 100,
},
);
await waitUntilReady();
const output = lastFrame();
warnings.forEach((warning) => {
expect(output).toContain(warning.message);
@@ -163,15 +161,11 @@ describe('Notifications', () => {
} as AppState;
mockUseAppContext.mockReturnValue(appState);
const { waitUntilReady, unmount } = await renderWithProviders(
<Notifications />,
{
appState,
settings,
width: 100,
},
);
await waitUntilReady();
const { unmount } = await renderWithProviders(<Notifications />, {
appState,
settings,
width: 100,
});
expect(persistentStateMock.set).toHaveBeenCalledWith(
'startupWarningCounts',
@@ -199,7 +193,7 @@ describe('Notifications', () => {
startupWarningCounts: { 'low-1': 3 },
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<Notifications />,
{
appState,
@@ -207,7 +201,6 @@ describe('Notifications', () => {
width: 100,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Low priority 1');
expect(output).toContain('High priority 1');
@@ -234,7 +227,6 @@ describe('Notifications', () => {
settings,
width: 100,
});
await waitUntilReady();
expect(lastFrame()).toContain('High priority 1');
await act(async () => {
@@ -253,7 +245,7 @@ describe('Notifications', () => {
updateInfo: null,
} as unknown as UIState;
mockUseUIState.mockReturnValue(uiState);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<Notifications />,
{
uiState,
@@ -261,7 +253,6 @@ describe('Notifications', () => {
width: 100,
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -273,7 +264,7 @@ describe('Notifications', () => {
updateInfo: null,
} as unknown as UIState;
mockUseUIState.mockReturnValue(uiState);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<Notifications />,
{
uiState,
@@ -281,7 +272,6 @@ describe('Notifications', () => {
width: 100,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -293,7 +283,7 @@ describe('Notifications', () => {
updateInfo: { message: 'Update available' },
} as unknown as UIState;
mockUseUIState.mockReturnValue(uiState);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<Notifications />,
{
uiState,
@@ -301,7 +291,6 @@ describe('Notifications', () => {
width: 100,
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -311,14 +300,13 @@ describe('Notifications', () => {
persistentStateMock.setData({ hasSeenScreenReaderNudge: false });
mockFsAccess.mockRejectedValue(new Error('No legacy file'));
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<Notifications />,
{
settings,
width: 100,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('screen reader-friendly view');
expect(persistentStateMock.set).toHaveBeenCalledWith(
@@ -352,14 +340,13 @@ describe('Notifications', () => {
mockUseIsScreenReaderEnabled.mockReturnValue(true);
persistentStateMock.setData({ hasSeenScreenReaderNudge: true });
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<Notifications />,
{
settings,
width: 100,
},
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
expect(persistentStateMock.set).not.toHaveBeenCalled();
@@ -29,7 +29,7 @@ describe('OverageMenuDialog', () => {
describe('rendering', () => {
it('should match snapshot with fallback available', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
@@ -38,36 +38,30 @@ describe('OverageMenuDialog', () => {
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should match snapshot without fallback', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={500}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should display the credit balance', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={200}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('200');
expect(output).toContain('AI Credits available');
@@ -75,15 +69,13 @@ describe('OverageMenuDialog', () => {
});
it('should display the model name', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('gemini-2.5-pro');
expect(output).toContain('Usage limit reached');
@@ -91,7 +83,7 @@ describe('OverageMenuDialog', () => {
});
it('should display reset time when provided', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
resetTime="3:45 PM"
@@ -99,8 +91,6 @@ describe('OverageMenuDialog', () => {
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('3:45 PM');
expect(output).toContain('Access resets at');
@@ -108,30 +98,26 @@ describe('OverageMenuDialog', () => {
});
it('should not display reset time when not provided', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).not.toContain('Access resets at');
unmount();
});
it('should display slash command hints', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
const output = lastFrame() ?? '';
expect(output).toContain('/stats');
expect(output).toContain('/model');
@@ -143,15 +129,13 @@ describe('OverageMenuDialog', () => {
describe('onChoice handling', () => {
it('should call onChoice with use_credits when selected', async () => {
// use_credits is the first item, so just press Enter
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
const { unmount, stdin } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\r');
await waitFor(() => {
@@ -162,15 +146,13 @@ describe('OverageMenuDialog', () => {
it('should call onChoice with manage when selected', async () => {
// manage is the second item: Down + Enter
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
const { unmount, stdin } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
@@ -183,7 +165,7 @@ describe('OverageMenuDialog', () => {
it('should call onChoice with use_fallback when selected', async () => {
// With fallback: items are [use_credits, manage, use_fallback, stop]
// use_fallback is the third item: Down x2 + Enter
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
const { unmount, stdin } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-3-flash-preview"
@@ -191,8 +173,6 @@ describe('OverageMenuDialog', () => {
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
@@ -206,15 +186,13 @@ describe('OverageMenuDialog', () => {
it('should call onChoice with stop when selected', async () => {
// Without fallback: items are [use_credits, manage, stop]
// stop is the third item: Down x2 + Enter
const { unmount, stdin, waitUntilReady } = await renderWithProviders(
const { unmount, stdin } = await renderWithProviders(
<OverageMenuDialog
failedModel="gemini-2.5-pro"
creditBalance={100}
onChoice={mockOnChoice}
/>,
);
await waitUntilReady();
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\x1b[B'); // Down arrow
writeKey(stdin, '\r');
@@ -72,10 +72,9 @@ describe('PermissionsModifyTrustDialog', () => {
});
it('should render the main dialog with current trust level', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('Modify Trust Level');
@@ -96,10 +95,9 @@ describe('PermissionsModifyTrustDialog', () => {
commitTrustLevelChange: mockCommitTrustLevelChange,
isFolderTrustEnabled: true,
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain(
@@ -120,10 +118,9 @@ describe('PermissionsModifyTrustDialog', () => {
commitTrustLevelChange: mockCommitTrustLevelChange,
isFolderTrustEnabled: true,
});
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain(
@@ -134,10 +131,9 @@ describe('PermissionsModifyTrustDialog', () => {
});
it('should render the labels with folder names', async () => {
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('Trust this folder (dir)');
@@ -152,7 +148,6 @@ describe('PermissionsModifyTrustDialog', () => {
await renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
@@ -191,7 +186,6 @@ describe('PermissionsModifyTrustDialog', () => {
await renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
@@ -226,7 +220,6 @@ describe('PermissionsModifyTrustDialog', () => {
await renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitUntilReady();
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
@@ -57,7 +57,7 @@ describe('PolicyUpdateDialog', () => {
});
it('renders correctly and matches snapshot', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
const { lastFrame } = await renderWithProviders(
<PolicyUpdateDialog
config={mockConfig}
request={mockRequest}
@@ -65,7 +65,6 @@ describe('PolicyUpdateDialog', () => {
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('New or changed workspace policies detected');
@@ -29,8 +29,8 @@ describe('ProQuotaDialog', () => {
});
describe('for flash model failures', () => {
it('should render "Keep trying" and "Stop" options', () => {
const { unmount } = render(
it('should render "Keep trying" and "Stop" options', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel={DEFAULT_GEMINI_FLASH_MODEL}
fallbackModel={DEFAULT_GEMINI_FLASH_MODEL}
@@ -63,8 +63,8 @@ describe('ProQuotaDialog', () => {
describe('for non-flash model failures', () => {
describe('when it is a terminal quota error', () => {
it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE', () => {
const { unmount } = render(
it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
@@ -101,8 +101,8 @@ describe('ProQuotaDialog', () => {
unmount();
});
it('should NOT render upgrade option for USE_GEMINI', () => {
const { unmount } = render(
it('should NOT render upgrade option for USE_GEMINI', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
@@ -134,8 +134,8 @@ describe('ProQuotaDialog', () => {
unmount();
});
it('should render "Keep trying" and "Stop" options when failed model and fallback model are the same', () => {
const { unmount } = render(
it('should render "Keep trying" and "Stop" options when failed model and fallback model are the same', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel={PREVIEW_GEMINI_MODEL}
fallbackModel={PREVIEW_GEMINI_MODEL}
@@ -165,8 +165,8 @@ describe('ProQuotaDialog', () => {
unmount();
});
it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE (free tier)', () => {
const { unmount } = render(
it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE (free tier)', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
@@ -203,8 +203,8 @@ describe('ProQuotaDialog', () => {
unmount();
});
it('should NOT render upgrade option for LOGIN_WITH_GOOGLE if tier is Ultra', () => {
const { unmount } = render(
it('should NOT render upgrade option for LOGIN_WITH_GOOGLE if tier is Ultra', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
@@ -239,8 +239,8 @@ describe('ProQuotaDialog', () => {
});
describe('when it is a capacity error', () => {
it('should render keep trying, switch, and stop options', () => {
const { unmount } = render(
it('should render keep trying, switch, and stop options', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
@@ -274,8 +274,8 @@ describe('ProQuotaDialog', () => {
});
describe('when it is a model not found error', () => {
it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE', () => {
const { unmount } = render(
it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel="gemini-3-pro-preview"
fallbackModel="gemini-2.5-pro"
@@ -312,8 +312,8 @@ describe('ProQuotaDialog', () => {
unmount();
});
it('should NOT render upgrade option for USE_GEMINI', () => {
const { unmount } = render(
it('should NOT render upgrade option for USE_GEMINI', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel="gemini-3-pro-preview"
fallbackModel="gemini-2.5-pro"
@@ -348,8 +348,8 @@ describe('ProQuotaDialog', () => {
});
describe('onChoice handling', () => {
it('should call onChoice with the selected value', () => {
const { unmount } = render(
it('should call onChoice with the selected value', async () => {
const { unmount } = await render(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
@@ -10,20 +10,18 @@ import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
describe('QueuedMessageDisplay', () => {
it('renders nothing when message queue is empty', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QueuedMessageDisplay messageQueue={[]} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('displays single queued message', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QueuedMessageDisplay messageQueue={['First message']} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Queued (press ↑ to edit):');
@@ -38,10 +36,9 @@ describe('QueuedMessageDisplay', () => {
'Third queued message',
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Queued (press ↑ to edit):');
@@ -60,10 +57,9 @@ describe('QueuedMessageDisplay', () => {
'Message 5',
];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Queued (press ↑ to edit):');
@@ -79,10 +75,9 @@ describe('QueuedMessageDisplay', () => {
it('normalizes whitespace in messages', async () => {
const messageQueue = ['Message with\tmultiple\n whitespace'];
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QueuedMessageDisplay messageQueue={messageQueue} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Queued (press ↑ to edit):');
@@ -43,8 +43,7 @@ describe('QuittingDisplay', () => {
mockUseUIState.mockReturnValue({
quittingMessages: null,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<QuittingDisplay />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<QuittingDisplay />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
@@ -58,8 +57,7 @@ describe('QuittingDisplay', () => {
quittingMessages: mockMessages,
constrainHeight: false,
} as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<QuittingDisplay />);
await waitUntilReady();
const { lastFrame, unmount } = await render(<QuittingDisplay />);
expect(lastFrame()).toContain('Goodbye');
expect(lastFrame()).toContain('See you later');
unmount();
@@ -20,72 +20,65 @@ describe('QuotaDisplay', () => {
vi.unstubAllEnvs();
});
it('should not render when remaining is undefined', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QuotaDisplay remaining={undefined} limit={100} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should not render when limit is undefined', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QuotaDisplay remaining={100} limit={undefined} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should not render when limit is 0', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QuotaDisplay remaining={100} limit={0} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should not render when usage < 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QuotaDisplay remaining={85} limit={100} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should render warning when used >= 80%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QuotaDisplay remaining={15} limit={100} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render critical when used >= 95%', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QuotaDisplay remaining={4} limit={100} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render with reset time when provided', async () => {
const resetTime = new Date(Date.now() + 3600000).toISOString(); // 1 hour from now
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QuotaDisplay remaining={15} limit={100} resetTime={resetTime} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should NOT render reset time when terse is true', async () => {
const resetTime = new Date(Date.now() + 3600000).toISOString();
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QuotaDisplay
remaining={15}
limit={100}
@@ -93,16 +86,14 @@ describe('QuotaDisplay', () => {
terse={true}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render terse limit reached message', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
const { lastFrame, unmount } = await render(
<QuotaDisplay remaining={0} limit={100} terse={true} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -24,10 +24,7 @@ describe('RawMarkdownIndicator', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
const { lastFrame, waitUntilReady, unmount } = render(
<RawMarkdownIndicator />,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<RawMarkdownIndicator />);
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('Option+M to toggle');
unmount();
@@ -37,10 +34,7 @@ describe('RawMarkdownIndicator', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
const { lastFrame, waitUntilReady, unmount } = render(
<RawMarkdownIndicator />,
);
await waitUntilReady();
const { lastFrame, unmount } = await render(<RawMarkdownIndicator />);
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('Alt+M to toggle');
unmount();
@@ -23,7 +23,7 @@ describe('RewindConfirmation', () => {
details: [{ fileName: 'test.ts', diff: '' }],
};
const onConfirm = vi.fn();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<RewindConfirmation
stats={stats}
onConfirm={onConfirm}
@@ -31,7 +31,6 @@ describe('RewindConfirmation', () => {
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain('Revert code changes');
@@ -40,7 +39,7 @@ describe('RewindConfirmation', () => {
it('renders correctly without stats', async () => {
const onConfirm = vi.fn();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<RewindConfirmation
stats={null}
onConfirm={onConfirm}
@@ -48,7 +47,6 @@ describe('RewindConfirmation', () => {
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).not.toContain('Revert code changes');
@@ -58,7 +56,7 @@ describe('RewindConfirmation', () => {
it('calls onConfirm with Cancel on Escape', async () => {
const onConfirm = vi.fn();
const { stdin, waitUntilReady, unmount } = await renderWithProviders(
const { stdin, unmount } = await renderWithProviders(
<RewindConfirmation
stats={null}
onConfirm={onConfirm}
@@ -66,7 +64,6 @@ describe('RewindConfirmation', () => {
/>,
{ width: 80 },
);
await waitUntilReady();
await act(async () => {
stdin.write('\x1b');
@@ -81,7 +78,7 @@ describe('RewindConfirmation', () => {
it('renders timestamp when provided', async () => {
const onConfirm = vi.fn();
const timestamp = new Date().toISOString();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<RewindConfirmation
stats={null}
onConfirm={onConfirm}
@@ -90,7 +87,6 @@ describe('RewindConfirmation', () => {
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).not.toContain('Revert code changes');
@@ -91,14 +91,13 @@ describe('RewindViewer', () => {
const conversation = createConversation([
{ type: 'user', content: 'Hello', id: '1', timestamp: '1' },
]);
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={vi.fn()}
onRewind={vi.fn()}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Rewind');
expect(lastFrame()).toContain('Hello');
unmount();
@@ -130,14 +129,13 @@ describe('RewindViewer', () => {
const conversation = createConversation(messages as MessageRecord[]);
const onExit = vi.fn();
const onRewind = vi.fn();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -162,7 +160,6 @@ describe('RewindViewer', () => {
onRewind={onRewind}
/>,
);
await waitUntilReady();
// Initial state
expect(lastFrame()).toMatchSnapshot('initial-state');
@@ -197,7 +194,6 @@ describe('RewindViewer', () => {
onRewind={vi.fn()}
/>,
);
await waitUntilReady();
act(() => {
stdin.write(sequence);
@@ -230,7 +226,6 @@ describe('RewindViewer', () => {
onRewind={vi.fn()}
/>,
);
await waitUntilReady();
// Up from first -> Last
act(() => {
@@ -308,7 +303,6 @@ describe('RewindViewer', () => {
onRewind={onRewind}
/>,
);
await waitUntilReady();
// Select
await act(async () => {
@@ -366,7 +360,6 @@ describe('RewindViewer', () => {
onRewind={onRewind}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -403,14 +396,13 @@ describe('RewindViewer', () => {
const onExit = vi.fn();
const onRewind = vi.fn();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot('initial');
@@ -422,18 +414,14 @@ describe('RewindViewer', () => {
];
conversation = createConversation(newMessages);
const {
lastFrame: lastFrame2,
waitUntilReady: waitUntilReady2,
unmount: unmount2,
} = await renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
await waitUntilReady2();
const { lastFrame: lastFrame2, unmount: unmount2 } =
await renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
expect(lastFrame2()).toMatchSnapshot('after-update');
unmount2();
@@ -451,15 +439,13 @@ it('renders accessible screen reader view when screen reader is enabled', async
const onExit = vi.fn();
const onRewind = vi.fn();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
const { lastFrame, unmount } = await renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Rewind - Select a conversation point:');
expect(frame).toContain('Stay at current position');

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