mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 22:51:00 -07:00
231 lines
7.5 KiB
TypeScript
231 lines
7.5 KiB
TypeScript
|
|
/**
|
||
|
|
* @license
|
||
|
|
* Copyright 2026 Google LLC
|
||
|
|
* SPDX-License-Identifier: Apache-2.0
|
||
|
|
*/
|
||
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
|
|
import { TestRig } from './test-helper.js';
|
||
|
|
import * as fs from 'node:fs';
|
||
|
|
import * as path from 'node:path';
|
||
|
|
|
||
|
|
describe('run_shell_command streaming to file regression', () => {
|
||
|
|
let rig: TestRig;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
rig = new TestRig();
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(async () => await rig.cleanup());
|
||
|
|
|
||
|
|
it('should stream large outputs to a file and verify full content presence', async () => {
|
||
|
|
await rig.setup(
|
||
|
|
'should stream large outputs to a file and verify full content presence',
|
||
|
|
{
|
||
|
|
settings: { tools: { core: ['run_shell_command'] } },
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
const numLines = 20000;
|
||
|
|
const testFileName = 'large_output_test.txt';
|
||
|
|
const testFilePath = path.join(rig.testDir!, testFileName);
|
||
|
|
|
||
|
|
// Create a ~20MB file with unique content at start and end
|
||
|
|
const startMarker = 'START_OF_FILE_MARKER';
|
||
|
|
const endMarker = 'END_OF_FILE_MARKER';
|
||
|
|
|
||
|
|
const stream = fs.createWriteStream(testFilePath);
|
||
|
|
stream.write(startMarker + '\n');
|
||
|
|
for (let i = 0; i < numLines; i++) {
|
||
|
|
stream.write(`Line ${i + 1}: ` + 'A'.repeat(1000) + '\n');
|
||
|
|
}
|
||
|
|
stream.write(endMarker + '\n');
|
||
|
|
await new Promise((resolve) => stream.end(resolve));
|
||
|
|
|
||
|
|
const fileSize = fs.statSync(testFilePath).size;
|
||
|
|
expect(fileSize).toBeGreaterThan(20000000);
|
||
|
|
|
||
|
|
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
|
||
|
|
await rig.run({ args: prompt });
|
||
|
|
|
||
|
|
let savedFilePath = '';
|
||
|
|
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||
|
|
if (fs.existsSync(tmpdir)) {
|
||
|
|
const files = fs.readdirSync(tmpdir, {
|
||
|
|
recursive: true,
|
||
|
|
withFileTypes: true,
|
||
|
|
});
|
||
|
|
for (const file of files) {
|
||
|
|
if (file.isFile() && file.name.endsWith('.txt')) {
|
||
|
|
// In Node 20+, recursive readdir returns Dirent objects where `parentPath` is the directory path,
|
||
|
|
// but sometimes `path` is used in older Node. fallback:
|
||
|
|
const parentDir =
|
||
|
|
(file as { parentPath?: string }).parentPath ??
|
||
|
|
(file as { path?: string }).path ??
|
||
|
|
tmpdir;
|
||
|
|
const p = path.join(parentDir, file.name);
|
||
|
|
const stat = fs.statSync(p);
|
||
|
|
if (Date.now() - stat.mtimeMs < 60000 && stat.size >= 20000000) {
|
||
|
|
savedFilePath = p;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
expect(
|
||
|
|
savedFilePath,
|
||
|
|
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
|
||
|
|
).toBeTruthy();
|
||
|
|
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
|
||
|
|
expect(savedContent).toContain(startMarker);
|
||
|
|
expect(savedContent).toContain(endMarker);
|
||
|
|
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
|
||
|
|
|
||
|
|
fs.unlinkSync(savedFilePath);
|
||
|
|
}, 120000);
|
||
|
|
|
||
|
|
it('should stream very large (50MB) outputs to a file and verify full content presence', async () => {
|
||
|
|
await rig.setup(
|
||
|
|
'should stream very large (50MB) outputs to a file and verify full content presence',
|
||
|
|
{
|
||
|
|
settings: { tools: { core: ['run_shell_command'] } },
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
const numLines = 1000000;
|
||
|
|
const testFileName = 'very_large_output_test.txt';
|
||
|
|
const testFilePath = path.join(rig.testDir!, testFileName);
|
||
|
|
|
||
|
|
// Create a ~50MB file with unique content at start and end
|
||
|
|
const startMarker = 'START_OF_FILE_MARKER';
|
||
|
|
const endMarker = 'END_OF_FILE_MARKER';
|
||
|
|
|
||
|
|
const stream = fs.createWriteStream(testFilePath);
|
||
|
|
stream.write(startMarker + '\n');
|
||
|
|
for (let i = 0; i < numLines; i++) {
|
||
|
|
stream.write(`Line ${i + 1}: ` + 'A'.repeat(40) + '\n');
|
||
|
|
}
|
||
|
|
stream.write(endMarker + '\n');
|
||
|
|
await new Promise((resolve) => stream.end(resolve));
|
||
|
|
|
||
|
|
const fileSize = fs.statSync(testFilePath).size;
|
||
|
|
expect(fileSize).toBeGreaterThan(45000000);
|
||
|
|
|
||
|
|
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
|
||
|
|
await rig.run({ args: prompt });
|
||
|
|
|
||
|
|
let savedFilePath = '';
|
||
|
|
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||
|
|
if (fs.existsSync(tmpdir)) {
|
||
|
|
const files = fs.readdirSync(tmpdir, {
|
||
|
|
recursive: true,
|
||
|
|
withFileTypes: true,
|
||
|
|
});
|
||
|
|
for (const file of files) {
|
||
|
|
if (file.isFile() && file.name.endsWith('.txt')) {
|
||
|
|
const parentDir =
|
||
|
|
(file as { parentPath?: string }).parentPath ??
|
||
|
|
(file as { path?: string }).path ??
|
||
|
|
tmpdir;
|
||
|
|
const p = path.join(parentDir, file.name);
|
||
|
|
const stat = fs.statSync(p);
|
||
|
|
// Look for file >= 20MB (since we expect 50MB, but allowing margin for the bug)
|
||
|
|
if (Date.now() - stat.mtimeMs < 60000 && stat.size >= 20000000) {
|
||
|
|
savedFilePath = p;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
expect(
|
||
|
|
savedFilePath,
|
||
|
|
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
|
||
|
|
).toBeTruthy();
|
||
|
|
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
|
||
|
|
expect(savedContent).toContain(startMarker);
|
||
|
|
expect(savedContent).toContain(endMarker);
|
||
|
|
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
|
||
|
|
|
||
|
|
fs.unlinkSync(savedFilePath);
|
||
|
|
}, 120000);
|
||
|
|
|
||
|
|
it('should produce clean output resolving carriage returns and backspaces', async () => {
|
||
|
|
await rig.setup(
|
||
|
|
'should produce clean output resolving carriage returns and backspaces',
|
||
|
|
{
|
||
|
|
settings: {
|
||
|
|
tools: { core: ['run_shell_command'] },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
const script = `
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
|
||
|
|
# Fill buffer to force file streaming/truncation
|
||
|
|
# 45000 chars to be safe (default threshold is 40000)
|
||
|
|
print('A' * 45000)
|
||
|
|
sys.stdout.flush()
|
||
|
|
|
||
|
|
# Test sequence
|
||
|
|
print('XXXXX', end='', flush=True)
|
||
|
|
time.sleep(0.5)
|
||
|
|
print('\\rYYYYY', end='', flush=True)
|
||
|
|
time.sleep(0.5)
|
||
|
|
print('\\nNext Line', end='', flush=True)
|
||
|
|
`;
|
||
|
|
const scriptPath = path.join(rig.testDir!, 'test_script.py');
|
||
|
|
fs.writeFileSync(scriptPath, script);
|
||
|
|
|
||
|
|
const prompt = `run_shell_command python3 "${scriptPath}"`;
|
||
|
|
await rig.run({ args: prompt });
|
||
|
|
|
||
|
|
let savedFilePath = '';
|
||
|
|
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||
|
|
if (fs.existsSync(tmpdir)) {
|
||
|
|
const findFiles = (dir: string): string[] => {
|
||
|
|
let results: string[] = [];
|
||
|
|
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||
|
|
for (const file of list) {
|
||
|
|
const fullPath = path.join(dir, file.name);
|
||
|
|
if (file.isDirectory()) {
|
||
|
|
results = results.concat(findFiles(fullPath));
|
||
|
|
} else if (file.isFile() && file.name.endsWith('.txt')) {
|
||
|
|
results.push(fullPath);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return results;
|
||
|
|
};
|
||
|
|
|
||
|
|
const files = findFiles(tmpdir);
|
||
|
|
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||
|
|
|
||
|
|
if (files.length > 0) {
|
||
|
|
savedFilePath = files[0];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
expect(savedFilePath, 'Output file should exist').toBeTruthy();
|
||
|
|
const content = fs.readFileSync(savedFilePath, 'utf8');
|
||
|
|
|
||
|
|
// Verify it contains the large chunk
|
||
|
|
expect(content).toContain('AAAA');
|
||
|
|
|
||
|
|
// Verify cleanup logic:
|
||
|
|
// 1. The final text "YYYYY" should be present.
|
||
|
|
expect(content).toContain('YYYYY');
|
||
|
|
// 2. The next line should be present.
|
||
|
|
expect(content).toContain('Next Line');
|
||
|
|
|
||
|
|
// 3. Verify overwrite happened.
|
||
|
|
// In raw output, we would have "XXXXX...YYYYY".
|
||
|
|
// In processed output, "YYYYY" overwrites "XXXXX".
|
||
|
|
// We confirm that escape codes are stripped (processed text).
|
||
|
|
|
||
|
|
// 4. Check for ANSI escape codes (like \\x1b) just in case
|
||
|
|
expect(content).not.toContain('\x1b');
|
||
|
|
}, 60000);
|
||
|
|
});
|