mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
8 Commits
write_file
...
v0.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b05fb545ef | |||
| d0ce3c4c5d | |||
| 8872ee0ace | |||
| 9d7b9e6cd1 | |||
| f9997f92c9 | |||
| aae64683ce | |||
| 356eb7ced0 | |||
| 2908555435 |
@@ -0,0 +1,54 @@
|
||||
description="Injects context of all relevant cli files"
|
||||
prompt = """
|
||||
The source code contains the content of absolutely every source code file in
|
||||
packages/cli. In addition to the source code, the following test files are
|
||||
included as they are test files that conform to the project's testing standards:
|
||||
`packages/cli/src/ui/components/InputPrompt.test.tsx` and `packages/cli/src/ui/App.test.tsx`.
|
||||
You should very rarely need to read any other files from packages/cli to resolve
|
||||
prompts.
|
||||
|
||||
!{find packages/cli -path packages/cli/dist -prune -o -type f \\( -name "*.ts" -o -name "*.tsx" \\) ! -name "*.test.ts" ! -name "*.test.tsx" ! -name "*.d.ts" -exec echo "--- {} ---" \\; -exec cat {} \\; && echo "--- packages/cli/src/ui/components/InputPrompt.test.tsx ---" && cat packages/cli/src/ui/components/InputPrompt.test.tsx && echo "--- packages/cli/src/ui/App.test.tsx ---" && cat packages/cli/src/ui/App.test.tsx}
|
||||
|
||||
**Pay extremely close attention to these files.** They define the project's
|
||||
core architecture, component patterns, and testing standards.
|
||||
|
||||
In addition to the code context, you MUST strictly adhere to the following frontend-specific development guidelines while adding code to packages/cli.
|
||||
|
||||
## Testing Standards
|
||||
* **Async Testing**: ALWAYS use `waitFor` from `packages/cli/src/test-utils/async.ts` instead of `vi.waitFor` to prevent flakiness and `act` warnings. NEVER use fixed waits (e.g., `await delay(100)`).
|
||||
* **State Changes**: Wrap all blocks that change component state in `act`.
|
||||
* **Snapshots**: Use `toMatchSnapshot` to verify rendering.
|
||||
* **Rendering**: Use `render` or `renderWithProviders` from `packages/cli/src/test-utils/render.tsx` instead of `ink-testing-library` directly.
|
||||
* **Mocking**:
|
||||
* Reuse existing mocks/fakes where possible.
|
||||
* **Parameterized Tests**: Use parameterized tests with explicit types to reduce duplication and ensure type safety.
|
||||
* Avoid mocking the file system, os, or child_process if at all possible; if you have to mock the Mock critical dependencies (`fs`, `os`, `child_process`) do so ONLY at the top of the file.
|
||||
|
||||
## React & Ink Architecture
|
||||
* **Keyboard Handling**: You MUST use `useKeyPress.ts` from Gemini CLI for keyboard handling, NOT the standard ink library. This is critical for supporting slow terminals and multiple events per frame.
|
||||
* Handle multiple events gracefully (often requires a reducer pattern, see `text-buffer.ts`).
|
||||
* **State Management**:
|
||||
* NEVER trigger side effects from within the body of a `setState` callback. Use a reducer or `useRef` if necessary.
|
||||
* Initialize state explicitly (e.g., use `undefined` rather than `true` if unknown).
|
||||
* **Performance**:
|
||||
* Avoid synchronous file I/O in components.
|
||||
* Do not introduce excessive property drilling; leverage or extend existing providers.
|
||||
|
||||
## Configuration & Settings
|
||||
* **Settings vs Args**: Use settings for user-configurable options; do not add new CLI arguments.
|
||||
* **Schema**: Add new settings to `packages/cli/src/config/settingsSchema.ts`.
|
||||
* **Documentation**:
|
||||
* If `showInDialog: true`, document in `docs/get-started/configuration.md`.
|
||||
* Ensure `requiresRestart` is correctly set.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
* **Registration**: Define new shortcuts in `packages/cli/src/config/keyBindings.ts`.
|
||||
* **Documentation**: Document all new shortcuts in `docs/cli/keyboard-shortcuts.md`.
|
||||
* **Compatibility**: Avoid function keys and common VSCode shortcuts. Be cautious with `Meta` key (Mac-limited support).
|
||||
|
||||
## General
|
||||
* **Logging**: Use `debugLogger` for errors. NEVER leave `console.log/warn/error` in the code.
|
||||
* **TypeScript**: Avoid the non-null assertion operator (`!`).
|
||||
|
||||
{{args}}.
|
||||
"""
|
||||
@@ -0,0 +1,57 @@
|
||||
description="Injects context of all relevant cli files"
|
||||
prompt = """
|
||||
The following output contains the complete
|
||||
source code of the gemini cli library (`packages/cli`), and {packages/core} and representative test files
|
||||
(`packages/cli/src/ui/components/InputPrompt.test.tsx` and `packages/cli/src/ui/App.test.tsx`) that best conform to the project's testing standards.
|
||||
**Pay extremely close attention to these files.** They define the project's
|
||||
core architecture, component patterns, and testing standards.
|
||||
|
||||
The source code contains the content of absolutely every source code file in
|
||||
packages/cli and packages/core. In addition to the source code, the following test files are
|
||||
included as they are test files that conform to the project's testing standards:
|
||||
`packages/cli/src/ui/components/InputPrompt.test.tsx` and `packages/cli/src/ui/App.test.tsx`.
|
||||
You should very rarely need to read any other files from packages/cli to resolve
|
||||
prompts.
|
||||
|
||||
!{find packages/cli packages/core \\( -path packages/cli/dist -o -path packages/core/dist -o -name node_modules \\) -prune -o -type f \\( -name "*.ts" -o -name "*.tsx" \\) ! -name "*.test.ts" ! -name "*.test.tsx" ! -name "*.d.ts" -exec echo "--- {} ---" \\; -exec cat {} \\; && echo "--- packages/cli/src/ui/components/InputPrompt.test.tsx ---" && cat packages/cli/src/ui/components/InputPrompt.test.tsx && echo "--- packages/cli/src/ui/App.test.tsx ---" && cat packages/cli/src/ui/App.test.tsx}
|
||||
|
||||
In addition to the code context, you MUST strictly adhere to the following frontend-specific development guidelines when writing code in packages/cli.
|
||||
|
||||
## Testing Standards
|
||||
* **Async Testing**: ALWAYS use `waitFor` from `packages/cli/src/test-utils/async.ts` instead of `vi.waitFor` to prevent flakiness and `act` warnings. NEVER use fixed waits (e.g., `await delay(100)`).
|
||||
* **State Changes**: Wrap all blocks that change component state in `act`.
|
||||
* **Snapshots**: Use `toMatchSnapshot` to verify rendering.
|
||||
* **Rendering**: Use `render` or `renderWithProviders` from `packages/cli/src/test-utils/render.tsx` instead of `ink-testing-library` directly.
|
||||
* **Mocking**:
|
||||
* Reuse existing mocks/fakes where possible.
|
||||
* **Parameterized Tests**: Use parameterized tests with explicit types to reduce duplication and ensure type safety.
|
||||
* Avoid mocking the file system, os, or child_process if at all possible; if you have to mock the Mock critical dependencies (`fs`, `os`, `child_process`) do so ONLY at the top of the file.
|
||||
|
||||
## React & Ink Architecture
|
||||
* **Keyboard Handling**: You MUST use `useKeyPress.ts` from Gemini CLI for keyboard handling, NOT the standard ink library. This is critical for supporting slow terminals and multiple events per frame.
|
||||
* Handle multiple events gracefully (often requires a reducer pattern, see `text-buffer.ts`).
|
||||
* **State Management**:
|
||||
* NEVER trigger side effects from within the body of a `setState` callback. Use a reducer or `useRef` if necessary.
|
||||
* Initialize state explicitly (e.g., use `undefined` rather than `true` if unknown).
|
||||
* **Performance**:
|
||||
* Avoid synchronous file I/O in components.
|
||||
* Do not introduce excessive property drilling; leverage or extend existing providers.
|
||||
|
||||
## Configuration & Settings
|
||||
* **Settings vs Args**: Use settings for user-configurable options; do not add new CLI arguments.
|
||||
* **Schema**: Add new settings to `packages/cli/src/config/settingsSchema.ts`.
|
||||
* **Documentation**:
|
||||
* If `showInDialog: true`, document in `docs/get-started/configuration.md`.
|
||||
* Ensure `requiresRestart` is correctly set.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
* **Registration**: Define new shortcuts in `packages/cli/src/config/keyBindings.ts`.
|
||||
* **Documentation**: Document all new shortcuts in `docs/cli/keyboard-shortcuts.md`.
|
||||
* **Compatibility**: Avoid function keys and common VSCode shortcuts. Be cautious with `Meta` key (Mac-limited support).
|
||||
|
||||
## General
|
||||
* **Logging**: Use `debugLogger` for errors. NEVER leave `console.log/warn/error` in the code.
|
||||
* **TypeScript**: Avoid the non-null assertion operator (`!`).
|
||||
|
||||
{{args}}.
|
||||
"""
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17614,7 +17614,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.2",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17904,7 +17904,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.30.0",
|
||||
@@ -18005,7 +18005,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"dependencies": {
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
|
||||
@@ -18149,7 +18149,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
@@ -18160,7 +18160,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.20.0-nightly.20251201.2fe609cb6"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.20.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"description": "Gemini CLI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.20.0-nightly.20251201.2fe609cb6"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.20.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
|
||||
@@ -50,7 +50,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
),
|
||||
),
|
||||
patchStdio: vi.fn(() => () => {}),
|
||||
createInkStdio: vi.fn(() => ({
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
stdout: {
|
||||
write: vi.fn((...args) =>
|
||||
process.stdout.write(
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
recordSlowRender,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
createInkStdio,
|
||||
createWorkingStdio,
|
||||
patchStdio,
|
||||
writeToStdout,
|
||||
writeToStderr,
|
||||
@@ -203,7 +203,7 @@ export async function startInteractiveUI(
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createInkStdio();
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
writeToStdout: vi.fn(),
|
||||
patchStdio: vi.fn(() => () => {}),
|
||||
createInkStdio: vi.fn(() => ({
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
stdout: {
|
||||
write: vi.fn(),
|
||||
columns: 80,
|
||||
|
||||
@@ -68,6 +68,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
getMetrics: vi.fn(),
|
||||
},
|
||||
coreEvents: mockCoreEvents,
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
createWorkingStdio,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
@@ -70,7 +71,8 @@ export async function runNonInteractive({
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
},
|
||||
});
|
||||
const textOutput = new TextOutput();
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
const textOutput = new TextOutput(workingStdout);
|
||||
|
||||
const handleUserFeedback = (payload: UserFeedbackPayload) => {
|
||||
const prefix = payload.severity.toUpperCase();
|
||||
|
||||
@@ -65,7 +65,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
),
|
||||
),
|
||||
patchStdio: vi.fn(() => () => {}),
|
||||
createInkStdio: vi.fn(() => ({
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
})),
|
||||
|
||||
@@ -13,6 +13,11 @@ import stripAnsi from 'strip-ansi';
|
||||
|
||||
export class TextOutput {
|
||||
private atStartOfLine = true;
|
||||
private outputStream: NodeJS.WriteStream;
|
||||
|
||||
constructor(outputStream: NodeJS.WriteStream = process.stdout) {
|
||||
this.outputStream = outputStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a string to stdout.
|
||||
@@ -22,7 +27,7 @@ export class TextOutput {
|
||||
if (str.length === 0) {
|
||||
return;
|
||||
}
|
||||
process.stdout.write(str);
|
||||
this.outputStream.write(str);
|
||||
const strippedStr = stripAnsi(str);
|
||||
if (strippedStr.length > 0) {
|
||||
this.atStartOfLine = strippedStr.endsWith('\n');
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
debugLogger,
|
||||
ReadManyFilesTool,
|
||||
getEffectiveModel,
|
||||
createWorkingStdio,
|
||||
startupProfiler,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from './acp.js';
|
||||
@@ -51,15 +52,10 @@ export async function runZedIntegration(
|
||||
settings: LoadedSettings,
|
||||
argv: CliArgs,
|
||||
) {
|
||||
const stdout = Writable.toWeb(process.stdout) as WritableStream;
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
const stdout = Writable.toWeb(workingStdout) as WritableStream;
|
||||
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
|
||||
|
||||
// Stdout is used to send messages to the client, so console.log/console.info
|
||||
// messages to stderr so that they don't interfere with ACP.
|
||||
console.log = console.error;
|
||||
console.info = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
new acp.AgentSideConnection(
|
||||
(client: acp.Client) => new GeminiAgent(config, settings, argv, client),
|
||||
stdout,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"description": "Gemini CLI Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -48,7 +48,7 @@ vi.mock('../utils/browser.js', () => ({
|
||||
vi.mock('../utils/stdio.js', () => ({
|
||||
writeToStdout: vi.fn(),
|
||||
writeToStderr: vi.fn(),
|
||||
createInkStdio: vi.fn(() => ({
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
})),
|
||||
|
||||
@@ -33,7 +33,7 @@ import { FORCE_ENCRYPTED_FILE_ENV_VAR } from '../mcp/token-storage/index.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
writeToStdout,
|
||||
createInkStdio,
|
||||
createWorkingStdio,
|
||||
writeToStderr,
|
||||
} from '../utils/stdio.js';
|
||||
import {
|
||||
@@ -334,7 +334,7 @@ async function authWithUserCode(client: OAuth2Client): Promise<boolean> {
|
||||
const code = await new Promise<string>((resolve, _) => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: createInkStdio().stdout,
|
||||
output: createWorkingStdio().stdout,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,24 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import EventEmitter from 'node:events';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import type { ShellOutputEvent } from './shellExecutionService.js';
|
||||
import type {
|
||||
ShellOutputEvent,
|
||||
ShellExecutionConfig,
|
||||
} from './shellExecutionService.js';
|
||||
import { ShellExecutionService } from './shellExecutionService.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import type { AnsiOutput, AnsiToken } from '../utils/terminalSerializer.js';
|
||||
|
||||
// Hoisted Mocks
|
||||
const mockPtySpawn = vi.hoisted(() => vi.fn());
|
||||
@@ -64,7 +75,7 @@ const mockProcessKill = vi
|
||||
.spyOn(process, 'kill')
|
||||
.mockImplementation(() => true);
|
||||
|
||||
const shellExecutionConfig = {
|
||||
const shellExecutionConfig: ShellExecutionConfig = {
|
||||
terminalWidth: 80,
|
||||
terminalHeight: 24,
|
||||
pager: 'cat',
|
||||
@@ -74,21 +85,19 @@ const shellExecutionConfig = {
|
||||
|
||||
const createExpectedAnsiOutput = (text: string | string[]): AnsiOutput => {
|
||||
const lines = Array.isArray(text) ? text : text.split('\n');
|
||||
const expected: AnsiOutput = Array.from(
|
||||
{ length: shellExecutionConfig.terminalHeight },
|
||||
(_, i) => [
|
||||
{
|
||||
text: expect.stringMatching((lines[i] || '').trim()),
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
fg: '',
|
||||
bg: '',
|
||||
},
|
||||
],
|
||||
);
|
||||
const len = (shellExecutionConfig.terminalHeight ?? 24) as number;
|
||||
const expected: AnsiOutput = Array.from({ length: len }, (_, i) => [
|
||||
{
|
||||
text: expect.stringMatching((lines[i] || '').trim()),
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
fg: '',
|
||||
bg: '',
|
||||
} as AnsiToken,
|
||||
]);
|
||||
return expected;
|
||||
};
|
||||
|
||||
@@ -242,6 +251,95 @@ describe('ShellExecutionService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should capture large output (10000 lines)', async () => {
|
||||
const lineCount = 10000;
|
||||
const lines = Array.from({ length: lineCount }, (_, i) => `line ${i}`);
|
||||
const expectedOutput = lines.join('\n');
|
||||
|
||||
const { result } = await simulateExecution(
|
||||
'large-output-command',
|
||||
(pty) => {
|
||||
// Send data in chunks to simulate realistic streaming
|
||||
// Use \r\n to ensure the terminal moves the cursor to the start of the line
|
||||
const chunkSize = 1000;
|
||||
for (let i = 0; i < lineCount; i += chunkSize) {
|
||||
const chunk = lines.slice(i, i + chunkSize).join('\r\n') + '\r\n';
|
||||
pty.onData.mock.calls[0][0](chunk);
|
||||
}
|
||||
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
// The terminal buffer output includes trailing spaces for each line (up to terminal width).
|
||||
// We trim each line to match our expected simple string.
|
||||
const processedOutput = result.output
|
||||
.split('\n')
|
||||
.map((l) => l.trimEnd())
|
||||
.join('\n')
|
||||
.trim();
|
||||
expect(processedOutput).toBe(expectedOutput);
|
||||
expect(result.output.split('\n').length).toBeGreaterThanOrEqual(
|
||||
lineCount,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not add extra padding but preserve explicit trailing whitespace', async () => {
|
||||
const { result } = await simulateExecution('cmd', (pty) => {
|
||||
// "value" should not get terminal-width padding
|
||||
// "value2 " should keep its spaces
|
||||
pty.onData.mock.calls[0][0]('value\r\nvalue2 ');
|
||||
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
||||
});
|
||||
|
||||
expect(result.output).toBe('value\nvalue2 ');
|
||||
});
|
||||
|
||||
it('should truncate output exceeding the scrollback limit', async () => {
|
||||
const scrollbackLimit = 100;
|
||||
const totalLines = 150;
|
||||
// Generate lines: "line 0", "line 1", ...
|
||||
const lines = Array.from({ length: totalLines }, (_, i) => `line ${i}`);
|
||||
|
||||
const { result } = await simulateExecution(
|
||||
'overflow-command',
|
||||
(pty) => {
|
||||
const chunk = lines.join('\r\n') + '\r\n';
|
||||
pty.onData.mock.calls[0][0](chunk);
|
||||
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
||||
},
|
||||
{ ...shellExecutionConfig, scrollback: scrollbackLimit },
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
|
||||
// The terminal should keep the *last* 'scrollbackLimit' lines + lines in the viewport.
|
||||
// xterm.js scrollback is the number of lines *above* the viewport.
|
||||
// So total lines retained = scrollback + rows.
|
||||
// However, our `getFullBufferText` implementation iterates the *active* buffer.
|
||||
// In headless xterm, the buffer length grows.
|
||||
// Let's verify that we have fewer lines than totalLines.
|
||||
|
||||
const outputLines = result.output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => l.trimEnd());
|
||||
|
||||
// We expect the *start* of the output to be truncated.
|
||||
// The first retained line should be > "line 0".
|
||||
// Specifically, if we sent 150 lines and have space for roughly 100 + viewport(24),
|
||||
// we should miss the first ~26 lines.
|
||||
|
||||
// Check that we lost some lines from the beginning
|
||||
expect(outputLines.length).toBeLessThan(totalLines);
|
||||
expect(outputLines[0]).not.toBe('line 0');
|
||||
|
||||
// Check that we have the *last* lines
|
||||
expect(outputLines[outputLines.length - 1]).toBe(
|
||||
`line ${totalLines - 1}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should call onPid with the process id', async () => {
|
||||
const abortController = new AbortController();
|
||||
const handle = await ShellExecutionService.execute(
|
||||
@@ -1136,3 +1234,170 @@ describe('ShellExecutionService execution method selection', () => {
|
||||
expect(result.executionMethod).toBe('child_process');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ShellExecutionService environment variables', () => {
|
||||
let mockPtyProcess: EventEmitter & {
|
||||
pid: number;
|
||||
kill: Mock;
|
||||
onData: Mock;
|
||||
onExit: Mock;
|
||||
write: Mock;
|
||||
resize: Mock;
|
||||
};
|
||||
let mockChildProcess: EventEmitter & Partial<ChildProcess>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules(); // Reset modules to ensure process.env changes are fresh
|
||||
|
||||
// Mock for pty
|
||||
mockPtyProcess = new EventEmitter() as EventEmitter & {
|
||||
pid: number;
|
||||
kill: Mock;
|
||||
onData: Mock;
|
||||
onExit: Mock;
|
||||
write: Mock;
|
||||
resize: Mock;
|
||||
};
|
||||
mockPtyProcess.pid = 12345;
|
||||
mockPtyProcess.kill = vi.fn();
|
||||
mockPtyProcess.onData = vi.fn();
|
||||
mockPtyProcess.onExit = vi.fn();
|
||||
mockPtyProcess.write = vi.fn();
|
||||
mockPtyProcess.resize = vi.fn();
|
||||
|
||||
mockPtySpawn.mockReturnValue(mockPtyProcess);
|
||||
mockGetPty.mockResolvedValue({
|
||||
module: { spawn: mockPtySpawn },
|
||||
name: 'mock-pty',
|
||||
});
|
||||
|
||||
// Mock for child_process
|
||||
mockChildProcess = new EventEmitter() as EventEmitter &
|
||||
Partial<ChildProcess>;
|
||||
mockChildProcess.stdout = new EventEmitter() as Readable;
|
||||
mockChildProcess.stderr = new EventEmitter() as Readable;
|
||||
mockChildProcess.kill = vi.fn();
|
||||
Object.defineProperty(mockChildProcess, 'pid', {
|
||||
value: 54321,
|
||||
configurable: true,
|
||||
});
|
||||
mockCpSpawn.mockReturnValue(mockChildProcess);
|
||||
|
||||
// Default exit behavior for mocks
|
||||
mockPtyProcess.onExit.mockImplementationOnce(({ exitCode, signal }) => {
|
||||
// Small delay to allow async ops to complete
|
||||
setTimeout(() => mockPtyProcess.emit('exit', { exitCode, signal }), 0);
|
||||
});
|
||||
mockChildProcess.on('exit', (code, signal) => {
|
||||
// Small delay to allow async ops to complete
|
||||
setTimeout(() => mockChildProcess.emit('close', code, signal), 0);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up process.env after each test
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should use a sanitized environment when in a GitHub run', async () => {
|
||||
// Mock the environment to simulate a GitHub Actions run
|
||||
vi.stubEnv('GITHUB_SHA', 'test-sha');
|
||||
vi.stubEnv('MY_SENSITIVE_VAR', 'secret-value'); // This should be stripped out
|
||||
vi.stubEnv('PATH', '/test/path'); // An essential var that should be kept
|
||||
vi.stubEnv('GEMINI_CLI_TEST_VAR', 'test-value'); // A test var that should be kept
|
||||
|
||||
vi.resetModules();
|
||||
const { ShellExecutionService } = await import(
|
||||
'./shellExecutionService.js'
|
||||
);
|
||||
|
||||
// Test pty path
|
||||
await ShellExecutionService.execute(
|
||||
'test-pty-command',
|
||||
'/',
|
||||
vi.fn(),
|
||||
new AbortController().signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
|
||||
const ptyEnv = mockPtySpawn.mock.calls[0][2].env;
|
||||
expect(ptyEnv).not.toHaveProperty('MY_SENSITIVE_VAR');
|
||||
expect(ptyEnv).toHaveProperty('PATH', '/test/path');
|
||||
expect(ptyEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');
|
||||
|
||||
// Ensure pty process exits for next test
|
||||
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
// Test child_process path
|
||||
mockGetPty.mockResolvedValue(null); // Force fallback
|
||||
await ShellExecutionService.execute(
|
||||
'test-cp-command',
|
||||
'/',
|
||||
vi.fn(),
|
||||
new AbortController().signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
|
||||
const cpEnv = mockCpSpawn.mock.calls[0][2].env;
|
||||
expect(cpEnv).not.toHaveProperty('MY_SENSITIVE_VAR');
|
||||
expect(cpEnv).toHaveProperty('PATH', '/test/path');
|
||||
expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
});
|
||||
|
||||
it('should include the full process.env when not in a GitHub run', async () => {
|
||||
vi.stubEnv('MY_TEST_VAR', 'test-value');
|
||||
vi.stubEnv('GITHUB_SHA', '');
|
||||
vi.stubEnv('SURFACE', '');
|
||||
vi.resetModules();
|
||||
const { ShellExecutionService } = await import(
|
||||
'./shellExecutionService.js'
|
||||
);
|
||||
|
||||
// Test pty path
|
||||
await ShellExecutionService.execute(
|
||||
'test-pty-command-no-github',
|
||||
'/',
|
||||
vi.fn(),
|
||||
new AbortController().signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
expect(mockPtySpawn).toHaveBeenCalled();
|
||||
const ptyEnv = mockPtySpawn.mock.calls[0][2].env;
|
||||
expect(ptyEnv).toHaveProperty('MY_TEST_VAR', 'test-value');
|
||||
expect(ptyEnv).toHaveProperty('GEMINI_CLI', '1');
|
||||
|
||||
// Ensure pty process exits
|
||||
mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
// Test child_process path (forcing fallback by making pty unavailable)
|
||||
mockGetPty.mockResolvedValue(null);
|
||||
await ShellExecutionService.execute(
|
||||
'test-cp-command-no-github',
|
||||
'/',
|
||||
vi.fn(),
|
||||
new AbortController().signal,
|
||||
true, // Still tries pty, but it will fall back
|
||||
shellExecutionConfig,
|
||||
);
|
||||
expect(mockCpSpawn).toHaveBeenCalled();
|
||||
const cpEnv = mockCpSpawn.mock.calls[0][2].env;
|
||||
expect(cpEnv).toHaveProperty('MY_TEST_VAR', 'test-value');
|
||||
expect(cpEnv).toHaveProperty('GEMINI_CLI', '1');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,11 @@ const { Terminal } = pkg;
|
||||
const SIGKILL_TIMEOUT_MS = 200;
|
||||
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
|
||||
|
||||
// We want to allow shell outputs that are close to the context window in size.
|
||||
// 300,000 lines is roughly equivalent to a large context window, ensuring
|
||||
// we capture significant output from long-running commands.
|
||||
export const SCROLLBACK_LIMIT = 300000;
|
||||
|
||||
const BASH_SHOPT_OPTIONS = 'promptvars nullglob extglob nocaseglob dotglob';
|
||||
const BASH_SHOPT_GUARD = `shopt -u ${BASH_SHOPT_OPTIONS};`;
|
||||
|
||||
@@ -77,6 +82,7 @@ export interface ShellExecutionConfig {
|
||||
defaultBg?: string;
|
||||
// Used for testing
|
||||
disableDynamicLineTrimming?: boolean;
|
||||
scrollback?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,12 +116,70 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
const lineContent = line ? line.translateToString() : '';
|
||||
const lineContent = line ? line.translateToString(true) : '';
|
||||
lines.push(lineContent);
|
||||
}
|
||||
return lines.join('\n').trimEnd();
|
||||
|
||||
// Remove trailing empty lines
|
||||
while (lines.length > 0 && lines[lines.length - 1] === '') {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
function getSanitizedEnv(): NodeJS.ProcessEnv {
|
||||
const isRunningInGithub =
|
||||
process.env['GITHUB_SHA'] || process.env['SURFACE'] === 'Github';
|
||||
|
||||
if (!isRunningInGithub) {
|
||||
// For local runs, we want to preserve the user's full environment.
|
||||
return { ...process.env };
|
||||
}
|
||||
|
||||
// For CI runs (GitHub), we sanitize the environment for security.
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
const essentialVars = [
|
||||
// Cross-platform
|
||||
'PATH',
|
||||
// Windows specific
|
||||
'Path',
|
||||
'SYSTEMROOT',
|
||||
'SystemRoot',
|
||||
'COMSPEC',
|
||||
'ComSpec',
|
||||
'PATHEXT',
|
||||
'WINDIR',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'USERPROFILE',
|
||||
'SYSTEMDRIVE',
|
||||
'SystemDrive',
|
||||
// Unix/Linux/macOS specific
|
||||
'HOME',
|
||||
'LANG',
|
||||
'SHELL',
|
||||
'TMPDIR',
|
||||
'USER',
|
||||
'LOGNAME',
|
||||
];
|
||||
|
||||
for (const key of essentialVars) {
|
||||
if (process.env[key] !== undefined) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Always carry over test-specific variables for our own integration tests.
|
||||
for (const key in process.env) {
|
||||
if (key.startsWith('GEMINI_CLI_TEST')) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* A centralized service for executing shell commands with robust process
|
||||
* management, cross-platform compatibility, and streaming output capabilities.
|
||||
@@ -217,7 +281,7 @@ export class ShellExecutionService {
|
||||
shell: false,
|
||||
detached: !isWindows,
|
||||
env: {
|
||||
...process.env,
|
||||
...getSanitizedEnv(),
|
||||
GEMINI_CLI: '1',
|
||||
TERM: 'xterm-256color',
|
||||
PAGER: 'cat',
|
||||
@@ -431,7 +495,7 @@ export class ShellExecutionService {
|
||||
cols,
|
||||
rows,
|
||||
env: {
|
||||
...process.env,
|
||||
...getSanitizedEnv(),
|
||||
GEMINI_CLI: '1',
|
||||
TERM: 'xterm-256color',
|
||||
PAGER: shellExecutionConfig.pager ?? 'cat',
|
||||
@@ -445,6 +509,7 @@ export class ShellExecutionService {
|
||||
allowProposedApi: true,
|
||||
cols,
|
||||
rows,
|
||||
scrollback: shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT,
|
||||
});
|
||||
headlessTerminal.scrollToTop();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { patchStdio, createInkStdio } from './stdio.js';
|
||||
import { patchStdio, createWorkingStdio } from './stdio.js';
|
||||
import { coreEvents } from './events.js';
|
||||
|
||||
vi.mock('./events.js', () => ({
|
||||
@@ -53,14 +53,14 @@ describe('stdio utils', () => {
|
||||
expect(process.stderr.write).toBe(originalStderrWrite);
|
||||
});
|
||||
|
||||
it('createInkStdio writes to real stdout/stderr bypassing patch', () => {
|
||||
it('createWorkingStdio writes to real stdout/stderr bypassing patch', () => {
|
||||
const cleanup = patchStdio();
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createInkStdio();
|
||||
const { stdout, stderr } = createWorkingStdio();
|
||||
|
||||
inkStdout.write('ink stdout');
|
||||
stdout.write('working stdout');
|
||||
expect(coreEvents.emitOutput).not.toHaveBeenCalled();
|
||||
|
||||
inkStderr.write('ink stderr');
|
||||
stderr.write('working stderr');
|
||||
expect(coreEvents.emitOutput).not.toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
|
||||
@@ -80,9 +80,9 @@ export function patchStdio(): () => void {
|
||||
/**
|
||||
* Creates proxies for process.stdout and process.stderr that use the real write methods
|
||||
* (writeToStdout and writeToStderr) bypassing any monkey patching.
|
||||
* This is used by Ink to render to the real output.
|
||||
* This is used to write to the real output even when stdio is patched.
|
||||
*/
|
||||
export function createInkStdio() {
|
||||
export function createWorkingStdio() {
|
||||
const inkStdout = new Proxy(process.stdout, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'write') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.20.0-nightly.20251201.2fe609cb6",
|
||||
"version": "0.20.0",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user